Learning ObjectivesBy the end of this chapter, you will be able to:
- Evaluate return specialists beyond average yards
- Measure kick coverage team effectiveness
- Analyze return vs touchback decisions
- Calculate expected points from returns
- Study return schemes and blocking effectiveness
Introduction
The return game is often called the "hidden" phase of special teams—capable of flipping field position, changing momentum, and occasionally producing game-changing touchdowns. Yet return game analytics has traditionally lagged behind other areas of football analysis, with teams and analysts often relying on simple metrics like average return yards.
Modern analytics reveals a more complex picture. Should a returner take the ball out from 8 yards deep in the end zone? How much value does an elite return specialist provide? Which coverage teams consistently limit return opportunities? With the introduction of new kickoff rules in 2024 designed to increase returns while improving safety, these questions have become even more critical.
This chapter explores comprehensive approaches to return game analytics, covering both kickoff and punt returns. We'll develop metrics that capture return value, evaluate specialists and coverage units, analyze optimal decision-making, and assess the impact of rule changes on return strategy.
The Return Game in Context
Return game analytics encompasses: - **Kickoff returns**: Decisions on touchbacks vs returns, return value, coverage effectiveness - **Punt returns**: Fair catch decisions, return value, directional punting impact - **Personnel evaluation**: Return specialist value, coverage team quality - **Strategic decisions**: When to return, scheme effectiveness, risk management - **Rule adaptations**: How rule changes affect optimal return strategyUnderstanding Return Game Value
The Expected Points Framework for Returns
Just as we use EPA to evaluate offensive plays, we can apply expected points to returns:
$$ \text{Return EPA} = \text{EP}_{\text{after return}} - \text{EP}_{\text{before return}} $$
However, returns have unique characteristics:
- Starting field position varies: Kickoffs typically start from touchbacks (25-yard line) or various return depths
- Risk/reward tradeoff: Returns can gain field position or lose it through fumbles
- Rule changes impact: New kickoff rules dramatically alter the expected value calculation
Components of Return Value
A comprehensive return evaluation includes:
- Field position gained: Where the return ended vs alternative (touchback/fair catch)
- Success probability: Rate of positive EPA returns
- Explosive play potential: Frequency of long returns (40+ yards)
- Turnover risk: Fumble rate on returns
- Penalty impact: How often returns are negated by penalties
Kickoff Return Analytics
Loading and Preparing Kickoff Data
#| label: setup-r
#| message: false
#| warning: false
library(tidyverse)
library(nflfastR)
library(nflplotR)
library(gt)
# Load play-by-play data
pbp <- load_pbp(2023)
# Filter for kickoffs
kickoffs <- pbp %>%
filter(play_type == "kickoff") %>%
filter(!is.na(kick_distance)) %>%
select(
game_id, game_date, posteam, defteam,
kickoff_returner_player_name, kick_distance,
return_yards, touchdown, fumble_lost,
drive_start_yard_line, penalty
) %>%
# Create binary return indicator
mutate(
returned = !is.na(return_yards) & return_yards > 0,
touchback = is.na(return_yards) | return_yards == 0,
# Standardize field position (0-100, offense perspective)
start_yardline = ifelse(!is.na(drive_start_yard_line),
drive_start_yard_line,
25) # Touchback
)
cat("Loaded", nrow(kickoffs), "kickoffs from 2023 season\n")
cat("Returns:", sum(kickoffs$returned, na.rm = TRUE), "\n")
cat("Touchbacks:", sum(kickoffs$touchback, na.rm = TRUE), "\n")
#| label: setup-py
#| message: false
#| warning: false
import pandas as pd
import numpy as np
import nfl_data_py as nfl
import matplotlib.pyplot as plt
import seaborn as sns
# Load play-by-play data
pbp = nfl.import_pbp_data([2023])
# Filter for kickoffs
kickoffs = pbp[pbp['play_type'] == 'kickoff'].copy()
kickoffs = kickoffs[kickoffs['kick_distance'].notna()]
# Select relevant columns
cols = ['game_id', 'game_date', 'posteam', 'defteam',
'kickoff_returner_player_name', 'kick_distance',
'return_yards', 'touchdown', 'fumble_lost',
'drive_start_yard_line', 'penalty']
kickoffs = kickoffs[cols].copy()
# Create binary return indicator
kickoffs['returned'] = (kickoffs['return_yards'].notna()) & (kickoffs['return_yards'] > 0)
kickoffs['touchback'] = (kickoffs['return_yards'].isna()) | (kickoffs['return_yards'] == 0)
# Standardize field position
kickoffs['start_yardline'] = kickoffs['drive_start_yard_line'].fillna(25)
print(f"Loaded {len(kickoffs):,} kickoffs from 2023 season")
print(f"Returns: {kickoffs['returned'].sum():,}")
print(f"Touchbacks: {kickoffs['touchback'].sum():,}")
Kickoff Return Metrics
Let's calculate comprehensive metrics for kickoff returns:
#| label: kickoff-metrics-r
#| message: false
#| warning: false
# Calculate expected points for field position
calculate_ep <- function(yardline) {
# Simplified EP model (actual model would be more sophisticated)
case_when(
yardline >= 95 ~ 6.5,
yardline >= 80 ~ 5.0,
yardline >= 60 ~ 3.5,
yardline >= 40 ~ 2.0,
yardline >= 20 ~ 0.5,
TRUE ~ -0.5
)
}
# Analyze kickoff returns vs touchbacks
kickoff_analysis <- kickoffs %>%
mutate(
ep_start = calculate_ep(start_yardline),
touchback_ep = calculate_ep(25),
return_value = ep_start - touchback_ep
) %>%
group_by(returned) %>%
summarise(
count = n(),
avg_start = mean(start_yardline, na.rm = TRUE),
avg_ep = mean(ep_start, na.rm = TRUE),
avg_return_value = mean(return_value, na.rm = TRUE),
success_rate = mean(return_value > 0, na.rm = TRUE),
td_rate = mean(touchdown, na.rm = TRUE),
fumble_rate = mean(fumble_lost, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(
decision = ifelse(returned, "Returned", "Touchback")
)
kickoff_analysis %>%
select(decision, count, avg_start, avg_ep,
avg_return_value, success_rate, td_rate, fumble_rate) %>%
gt() %>%
cols_label(
decision = "Decision",
count = "Count",
avg_start = "Avg Start",
avg_ep = "Avg EP",
avg_return_value = "Return Value",
success_rate = "Success Rate",
td_rate = "TD Rate",
fumble_rate = "Fumble Rate"
) %>%
fmt_number(
columns = c(avg_start, avg_ep, avg_return_value),
decimals = 2
) %>%
fmt_percent(
columns = c(success_rate, td_rate, fumble_rate),
decimals = 1
) %>%
tab_header(
title = "Kickoff Return vs Touchback Analysis",
subtitle = "2023 NFL Season"
)
#| label: kickoff-metrics-py
#| message: false
#| warning: false
def calculate_ep(yardline):
"""Simplified EP model for field position"""
if yardline >= 95:
return 6.5
elif yardline >= 80:
return 5.0
elif yardline >= 60:
return 3.5
elif yardline >= 40:
return 2.0
elif yardline >= 20:
return 0.5
else:
return -0.5
# Calculate EP and return value
kickoffs['ep_start'] = kickoffs['start_yardline'].apply(calculate_ep)
kickoffs['touchback_ep'] = 25.0 # EP for 25-yard line
kickoffs['return_value'] = kickoffs['ep_start'] - calculate_ep(25)
# Analyze returns vs touchbacks
kickoff_summary = kickoffs.groupby('returned').agg({
'game_id': 'count',
'start_yardline': 'mean',
'ep_start': 'mean',
'return_value': 'mean',
'touchdown': 'mean',
'fumble_lost': 'mean'
}).reset_index()
kickoff_summary.columns = ['returned', 'count', 'avg_start', 'avg_ep',
'avg_return_value', 'td_rate', 'fumble_rate']
# Calculate success rate
for idx, row in kickoff_summary.iterrows():
returns = kickoffs[kickoffs['returned'] == row['returned']]
success_rate = (returns['return_value'] > 0).mean()
kickoff_summary.loc[idx, 'success_rate'] = success_rate
kickoff_summary['decision'] = kickoff_summary['returned'].map({
True: 'Returned', False: 'Touchback'
})
print("\nKickoff Return vs Touchback Analysis (2023 NFL Season)")
print("=" * 70)
print(kickoff_summary[['decision', 'count', 'avg_start', 'avg_ep',
'avg_return_value', 'success_rate', 'td_rate',
'fumble_rate']].to_string(index=False))
Return vs Touchback Decision Analysis
A critical decision for returners is whether to take the ball out of the end zone. Let's analyze this by kick depth:
#| label: fig-return-decision-r
#| fig-cap: "Average starting field position by return decision and kick depth"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
# Analyze returns from various depths in end zone
depth_analysis <- kickoffs %>%
mutate(
kick_into_ez = kick_distance >= 100,
ez_depth = case_when(
!kick_into_ez ~ "Short of EZ",
kick_distance >= 100 & kick_distance < 103 ~ "0-3 yards deep",
kick_distance >= 103 & kick_distance < 106 ~ "3-6 yards deep",
kick_distance >= 106 ~ "6+ yards deep",
TRUE ~ "Other"
)
) %>%
filter(ez_depth != "Other") %>%
group_by(ez_depth, returned) %>%
summarise(
count = n(),
avg_start = mean(start_yardline, na.rm = TRUE),
success_rate = mean(start_yardline > 25, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(
decision = ifelse(returned, "Returned", "Touchback")
)
ggplot(depth_analysis, aes(x = ez_depth, y = avg_start, fill = decision)) +
geom_col(position = "dodge", alpha = 0.8) +
geom_hline(yintercept = 25, linetype = "dashed", color = "red", size = 1) +
geom_text(
aes(label = round(avg_start, 1)),
position = position_dodge(width = 0.9),
vjust = -0.5,
size = 3
) +
scale_fill_manual(values = c("Returned" = "#00BFC4", "Touchback" = "#F8766D")) +
labs(
title = "Return vs Touchback Decision by Kick Depth",
subtitle = "Red line indicates 25-yard touchback line",
x = "Kick Depth into End Zone",
y = "Average Starting Field Position",
fill = "Decision",
caption = "Data: nflfastR | 2023 NFL Season"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "top"
)
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
#| label: fig-return-decision-py
#| fig-cap: "Average starting field position by return decision and kick depth - Python"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
# Analyze returns from various depths
def classify_depth(distance):
if pd.isna(distance):
return "Other"
if distance < 100:
return "Short of EZ"
elif distance < 103:
return "0-3 yards deep"
elif distance < 106:
return "3-6 yards deep"
else:
return "6+ yards deep"
kickoffs['ez_depth'] = kickoffs['kick_distance'].apply(classify_depth)
depth_data = kickoffs[kickoffs['ez_depth'] != "Other"].copy()
depth_data['decision'] = depth_data['returned'].map({True: 'Returned', False: 'Touchback'})
depth_summary = depth_data.groupby(['ez_depth', 'decision']).agg({
'game_id': 'count',
'start_yardline': 'mean'
}).reset_index()
depth_summary.columns = ['ez_depth', 'decision', 'count', 'avg_start']
# Create visualization
fig, ax = plt.subplots(figsize=(10, 6))
depths = ["Short of EZ", "0-3 yards deep", "3-6 yards deep", "6+ yards deep"]
x = np.arange(len(depths))
width = 0.35
returned = depth_summary[depth_summary['decision'] == 'Returned']
touchback = depth_summary[depth_summary['decision'] == 'Touchback']
bars1 = ax.bar(x - width/2,
[returned[returned['ez_depth'] == d]['avg_start'].values[0]
if len(returned[returned['ez_depth'] == d]) > 0 else 0
for d in depths],
width, label='Returned', alpha=0.8, color='#00BFC4')
bars2 = ax.bar(x + width/2,
[touchback[touchback['ez_depth'] == d]['avg_start'].values[0]
if len(touchback[touchback['ez_depth'] == d]) > 0 else 0
for d in depths],
width, label='Touchback', alpha=0.8, color='#F8766D')
ax.axhline(y=25, color='red', linestyle='--', linewidth=2, alpha=0.7)
ax.set_xlabel('Kick Depth into End Zone', fontsize=12)
ax.set_ylabel('Average Starting Field Position', fontsize=12)
ax.set_title('Return vs Touchback Decision by Kick Depth\nRed line indicates 25-yard touchback line',
fontsize=14, fontweight='bold')
ax.set_xticks(x)
ax.set_xticklabels(depths, rotation=45, ha='right')
ax.legend(title='Decision', loc='upper right')
ax.text(0.98, 0.02, 'Data: nfl_data_py | 2023 NFL Season',
transform=ax.transAxes, ha='right', fontsize=8, style='italic')
plt.tight_layout()
plt.show()
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
Punt Return Analytics
Punt Return Metrics
Punt returns differ from kickoff returns in several key ways:
- Fair catch option: More strategic than touchbacks on kickoffs
- Field position context: Punts occur throughout the field
- Hang time importance: Affects ability to set up return
- Directional punting: Limits return opportunities
#| label: punt-return-analysis-r
#| message: false
#| warning: false
# Filter for punts
punts <- pbp %>%
filter(play_type == "punt") %>%
filter(!is.na(kick_distance)) %>%
mutate(
fair_catch = if_else(is.na(return_yards) & !is.na(kick_distance), 1, 0),
returned = if_else(!is.na(return_yards) & return_yards > 0, 1, 0),
touchback = if_else(touchback == 1, 1, 0),
# Calculate net punting value
punt_start = 100 - yardline_100,
punt_end = ifelse(!is.na(drive_start_yard_line),
drive_start_yard_line,
100 - yardline_100 + kick_distance),
net_yards = punt_end - punt_start
)
# Analyze punt outcomes
punt_outcomes <- punts %>%
mutate(
outcome = case_when(
fair_catch == 1 ~ "Fair Catch",
returned == 1 ~ "Return",
touchback == 1 ~ "Touchback",
TRUE ~ "Downed"
)
) %>%
group_by(outcome) %>%
summarise(
count = n(),
avg_distance = mean(kick_distance, na.rm = TRUE),
avg_net = mean(net_yards, na.rm = TRUE),
avg_return = mean(return_yards, na.rm = TRUE),
td_rate = mean(touchdown, na.rm = TRUE),
.groups = "drop"
)
punt_outcomes %>%
gt() %>%
cols_label(
outcome = "Outcome",
count = "Count",
avg_distance = "Avg Distance",
avg_net = "Avg Net Yards",
avg_return = "Avg Return",
td_rate = "TD Rate"
) %>%
fmt_number(
columns = c(avg_distance, avg_net, avg_return),
decimals = 1
) %>%
fmt_percent(
columns = td_rate,
decimals = 2
) %>%
tab_header(
title = "Punt Outcome Analysis",
subtitle = "2023 NFL Season"
)
#| label: punt-return-analysis-py
#| message: false
#| warning: false
# Filter for punts
punts = pbp[pbp['play_type'] == 'punt'].copy()
punts = punts[punts['kick_distance'].notna()]
# Calculate outcomes
punts['fair_catch'] = (punts['return_yards'].isna()) & (punts['kick_distance'].notna())
punts['returned'] = (punts['return_yards'].notna()) & (punts['return_yards'] > 0)
# Calculate net punting value
punts['punt_start'] = 100 - punts['yardline_100']
punts['punt_end'] = punts['drive_start_yard_line'].fillna(
100 - punts['yardline_100'] + punts['kick_distance']
)
punts['net_yards'] = punts['punt_end'] - punts['punt_start']
# Classify outcomes
def classify_punt_outcome(row):
if row['fair_catch']:
return "Fair Catch"
elif row['returned']:
return "Return"
elif row.get('touchback', 0) == 1:
return "Touchback"
else:
return "Downed"
punts['outcome'] = punts.apply(classify_punt_outcome, axis=1)
# Summary statistics
punt_summary = punts.groupby('outcome').agg({
'game_id': 'count',
'kick_distance': 'mean',
'net_yards': 'mean',
'return_yards': 'mean',
'touchdown': 'mean'
}).reset_index()
punt_summary.columns = ['outcome', 'count', 'avg_distance',
'avg_net', 'avg_return', 'td_rate']
print("\nPunt Outcome Analysis (2023 NFL Season)")
print("=" * 60)
print(punt_summary.to_string(index=False))
Fair Catch Decision Analysis
When should a returner call for a fair catch? Let's analyze this decision:
#| label: fig-fair-catch-analysis-r
#| fig-cap: "Fair catch rate by field position"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
# Analyze fair catch decisions by field position
fair_catch_analysis <- punts %>%
mutate(
field_zone = case_when(
punt_end <= 10 ~ "Own 0-10",
punt_end <= 20 ~ "Own 10-20",
punt_end <= 35 ~ "Own 20-35",
punt_end <= 50 ~ "Own 35-50",
punt_end <= 65 ~ "Opp 50-35",
punt_end <= 80 ~ "Opp 35-20",
TRUE ~ "Opp 20-0"
)
) %>%
group_by(field_zone) %>%
summarise(
total_punts = n(),
fair_catches = sum(fair_catch, na.rm = TRUE),
returns = sum(returned, na.rm = TRUE),
fc_rate = mean(fair_catch, na.rm = TRUE),
avg_return_yards = mean(return_yards[returned == 1], na.rm = TRUE),
.groups = "drop"
) %>%
arrange(desc(total_punts))
# Visualize fair catch rate
ggplot(fair_catch_analysis, aes(x = reorder(field_zone, -fc_rate), y = fc_rate)) +
geom_col(fill = "#619CFF", alpha = 0.8) +
geom_text(aes(label = paste0(round(fc_rate * 100, 1), "%")),
vjust = -0.5, size = 3.5) +
scale_y_continuous(labels = scales::percent_format(), limits = c(0, 1)) +
labs(
title = "Fair Catch Rate by Field Position",
subtitle = "Higher rates in favorable field position suggest conservative strategy",
x = "Field Zone",
y = "Fair Catch Rate",
caption = "Data: nflfastR | 2023 NFL Season"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
axis.text.x = element_text(angle = 45, hjust = 1)
)
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
#| label: fig-fair-catch-analysis-py
#| fig-cap: "Fair catch rate by field position - Python"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
# Classify field zones
def classify_field_zone(yardline):
if pd.isna(yardline):
return "Unknown"
if yardline <= 10:
return "Own 0-10"
elif yardline <= 20:
return "Own 10-20"
elif yardline <= 35:
return "Own 20-35"
elif yardline <= 50:
return "Own 35-50"
elif yardline <= 65:
return "Opp 50-35"
elif yardline <= 80:
return "Opp 35-20"
else:
return "Opp 20-0"
punts['field_zone'] = punts['punt_end'].apply(classify_field_zone)
# Calculate fair catch rates
fc_analysis = punts[punts['field_zone'] != 'Unknown'].groupby('field_zone').agg({
'game_id': 'count',
'fair_catch': 'sum',
'returned': 'sum'
}).reset_index()
fc_analysis.columns = ['field_zone', 'total_punts', 'fair_catches', 'returns']
fc_analysis['fc_rate'] = fc_analysis['fair_catches'] / fc_analysis['total_punts']
# Sort by fair catch rate
fc_analysis = fc_analysis.sort_values('fc_rate', ascending=False)
# Visualize
fig, ax = plt.subplots(figsize=(10, 6))
bars = ax.bar(range(len(fc_analysis)), fc_analysis['fc_rate'],
alpha=0.8, color='#619CFF')
# Add value labels
for i, (idx, row) in enumerate(fc_analysis.iterrows()):
ax.text(i, row['fc_rate'] + 0.02, f"{row['fc_rate']*100:.1f}%",
ha='center', va='bottom', fontsize=10)
ax.set_xticks(range(len(fc_analysis)))
ax.set_xticklabels(fc_analysis['field_zone'], rotation=45, ha='right')
ax.set_ylabel('Fair Catch Rate', fontsize=12)
ax.set_xlabel('Field Zone', fontsize=12)
ax.set_ylim(0, 1.0)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0%}'))
ax.set_title('Fair Catch Rate by Field Position\nHigher rates in favorable field position suggest conservative strategy',
fontsize=14, fontweight='bold')
ax.text(0.98, 0.02, 'Data: nfl_data_py | 2023 NFL Season',
transform=ax.transAxes, ha='right', fontsize=8, style='italic')
plt.tight_layout()
plt.show()
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
Return Specialist Evaluation
Individual Returner Metrics
Evaluating return specialists requires looking beyond average yards:
#| label: return-specialist-rankings-r
#| message: false
#| warning: false
# Combine kickoff and punt returns
kickoff_returners <- kickoffs %>%
filter(returned == TRUE) %>%
filter(!is.na(kickoff_returner_player_name)) %>%
group_by(returner = kickoff_returner_player_name) %>%
summarise(
ko_returns = n(),
ko_avg = mean(return_yards, na.rm = TRUE),
ko_long = max(return_yards, na.rm = TRUE),
ko_td = sum(touchdown, na.rm = TRUE),
ko_fumbles = sum(fumble_lost, na.rm = TRUE),
.groups = "drop"
)
punt_returners <- punts %>%
filter(returned == 1) %>%
filter(!is.na(punt_returner_player_name)) %>%
group_by(returner = punt_returner_player_name) %>%
summarise(
pr_returns = n(),
pr_avg = mean(return_yards, na.rm = TRUE),
pr_long = max(return_yards, na.rm = TRUE),
pr_td = sum(touchdown, na.rm = TRUE),
pr_fumbles = sum(fumble_lost, na.rm = TRUE),
.groups = "drop"
)
# Combine and calculate composite score
return_specialists <- full_join(kickoff_returners, punt_returners, by = "returner") %>%
mutate(
total_returns = coalesce(ko_returns, 0) + coalesce(pr_returns, 0),
total_td = coalesce(ko_td, 0) + coalesce(pr_td, 0),
total_fumbles = coalesce(ko_fumbles, 0) + coalesce(pr_fumbles, 0)
) %>%
filter(total_returns >= 10) %>%
arrange(desc(total_td), desc(total_returns))
# Top return specialists
return_specialists %>%
head(10) %>%
select(returner, ko_returns, ko_avg, pr_returns, pr_avg,
total_td, total_fumbles) %>%
gt() %>%
cols_label(
returner = "Player",
ko_returns = "KO Ret",
ko_avg = "KO Avg",
pr_returns = "PR Ret",
pr_avg = "PR Avg",
total_td = "TDs",
total_fumbles = "Fumbles"
) %>%
fmt_number(
columns = c(ko_avg, pr_avg),
decimals = 1
) %>%
fmt_missing(
columns = everything(),
missing_text = "-"
) %>%
tab_header(
title = "Top Return Specialists - 2023",
subtitle = "Minimum 10 total returns"
) %>%
tab_style(
style = cell_fill(color = "#E8F4F8"),
locations = cells_body(
columns = everything(),
rows = total_td > 0
)
)
#| label: return-specialist-rankings-py
#| message: false
#| warning: false
# Analyze kickoff returners
ko_returners = kickoffs[
(kickoffs['returned'] == True) &
(kickoffs['kickoff_returner_player_name'].notna())
].copy()
ko_summary = ko_returners.groupby('kickoff_returner_player_name').agg({
'return_yards': ['count', 'mean', 'max'],
'touchdown': 'sum',
'fumble_lost': 'sum'
}).reset_index()
ko_summary.columns = ['returner', 'ko_returns', 'ko_avg', 'ko_long', 'ko_td', 'ko_fumbles']
# Analyze punt returners
pr_returners = punts[
(punts['returned'] == 1) &
(punts['punt_returner_player_name'].notna())
].copy()
pr_summary = pr_returners.groupby('punt_returner_player_name').agg({
'return_yards': ['count', 'mean', 'max'],
'touchdown': 'sum',
'fumble_lost': 'sum'
}).reset_index()
pr_summary.columns = ['returner', 'pr_returns', 'pr_avg', 'pr_long', 'pr_td', 'pr_fumbles']
# Combine
specialists = pd.merge(ko_summary, pr_summary, on='returner', how='outer')
# Calculate totals
specialists['total_returns'] = (
specialists['ko_returns'].fillna(0) + specialists['pr_returns'].fillna(0)
)
specialists['total_td'] = specialists['ko_td'].fillna(0) + specialists['pr_td'].fillna(0)
specialists['total_fumbles'] = (
specialists['ko_fumbles'].fillna(0) + specialists['pr_fumbles'].fillna(0)
)
# Filter and sort
specialists = specialists[specialists['total_returns'] >= 10].copy()
specialists = specialists.sort_values(['total_td', 'total_returns'], ascending=False)
print("\nTop Return Specialists - 2023 (Minimum 10 returns)")
print("=" * 80)
print(specialists[['returner', 'ko_returns', 'ko_avg', 'pr_returns', 'pr_avg',
'total_td', 'total_fumbles']].head(10).to_string(index=False))
Expected Points Added by Returners
A more sophisticated approach uses EPA:
#| label: returner-epa-r
#| message: false
#| warning: false
# Calculate EPA for kickoff returns
ko_with_epa <- kickoffs %>%
filter(returned == TRUE) %>%
filter(!is.na(kickoff_returner_player_name)) %>%
mutate(
baseline_ep = calculate_ep(25), # Touchback EP
actual_ep = calculate_ep(start_yardline),
epa = actual_ep - baseline_ep
)
# Returner EPA rankings
returner_epa <- ko_with_epa %>%
group_by(returner = kickoff_returner_player_name) %>%
summarise(
returns = n(),
avg_yards = mean(return_yards, na.rm = TRUE),
total_epa = sum(epa, na.rm = TRUE),
epa_per_return = mean(epa, na.rm = TRUE),
success_rate = mean(epa > 0, na.rm = TRUE),
explosive_rate = mean(return_yards >= 40, na.rm = TRUE),
.groups = "drop"
) %>%
filter(returns >= 15) %>%
arrange(desc(total_epa))
# Visualize top returners
returner_epa %>%
head(15) %>%
ggplot(aes(x = reorder(returner, total_epa), y = total_epa)) +
geom_col(aes(fill = epa_per_return), alpha = 0.8) +
geom_text(aes(label = round(total_epa, 1)), hjust = -0.2, size = 3) +
scale_fill_gradient2(
low = "#d73027", mid = "#ffffbf", high = "#1a9850",
midpoint = 0, name = "EPA/Return"
) +
coord_flip() +
labs(
title = "Top Kickoff Returners by Total EPA",
subtitle = "2023 NFL Season (min. 15 returns)",
x = "",
y = "Total Expected Points Added",
caption = "Data: nflfastR"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
legend.position = "right"
)
#| label: returner-epa-py
#| message: false
#| warning: false
#| fig-width: 10
#| fig-height: 8
# Calculate EPA for returns
ko_with_epa = kickoffs[
(kickoffs['returned'] == True) &
(kickoffs['kickoff_returner_player_name'].notna())
].copy()
ko_with_epa['baseline_ep'] = calculate_ep(25)
ko_with_epa['actual_ep'] = ko_with_epa['start_yardline'].apply(calculate_ep)
ko_with_epa['epa'] = ko_with_epa['actual_ep'] - ko_with_epa['baseline_ep']
# Calculate returner metrics
returner_metrics = ko_with_epa.groupby('kickoff_returner_player_name').agg({
'return_yards': ['count', 'mean'],
'epa': ['sum', 'mean']
}).reset_index()
returner_metrics.columns = ['returner', 'returns', 'avg_yards', 'total_epa', 'epa_per_return']
# Add success and explosive rates
for idx, row in returner_metrics.iterrows():
player_data = ko_with_epa[ko_with_epa['kickoff_returner_player_name'] == row['returner']]
returner_metrics.loc[idx, 'success_rate'] = (player_data['epa'] > 0).mean()
returner_metrics.loc[idx, 'explosive_rate'] = (player_data['return_yards'] >= 40).mean()
# Filter and sort
returner_metrics = returner_metrics[returner_metrics['returns'] >= 15].copy()
returner_metrics = returner_metrics.sort_values('total_epa', ascending=False)
# Visualize top returners
top_returners = returner_metrics.head(15)
fig, ax = plt.subplots(figsize=(10, 8))
# Create color map based on EPA per return
colors = plt.cm.RdYlGn(
(top_returners['epa_per_return'] - top_returners['epa_per_return'].min()) /
(top_returners['epa_per_return'].max() - top_returners['epa_per_return'].min())
)
bars = ax.barh(range(len(top_returners)), top_returners['total_epa'],
color=colors, alpha=0.8)
# Add value labels
for i, (idx, row) in enumerate(top_returners.iterrows()):
ax.text(row['total_epa'] + 0.5, i, f"{row['total_epa']:.1f}",
va='center', fontsize=9)
ax.set_yticks(range(len(top_returners)))
ax.set_yticklabels(top_returners['returner'])
ax.set_xlabel('Total Expected Points Added', fontsize=12)
ax.set_title('Top Kickoff Returners by Total EPA\n2023 NFL Season (min. 15 returns)',
fontsize=14, fontweight='bold')
ax.text(0.98, 0.02, 'Data: nfl_data_py',
transform=ax.transAxes, ha='right', fontsize=8, style='italic')
plt.tight_layout()
plt.show()
print("\nTop 10 Returners by EPA/Return:")
print(returner_metrics.head(10)[['returner', 'returns', 'total_epa',
'epa_per_return', 'success_rate']].to_string(index=False))
Coverage Team Effectiveness
Measuring Coverage Units
Coverage teams can be evaluated by how they limit return value:
#| label: coverage-team-analysis-r
#| message: false
#| warning: false
# Analyze kickoff coverage by team
coverage_stats <- kickoffs %>%
group_by(team = posteam) %>%
summarise(
kickoffs = n(),
touchback_rate = mean(touchback, na.rm = TRUE),
return_rate = mean(returned, na.rm = TRUE),
avg_return = mean(return_yards[returned == TRUE], na.rm = TRUE),
avg_start = mean(start_yardline, na.rm = TRUE),
td_allowed = sum(touchdown, na.rm = TRUE),
long_returns = sum(return_yards >= 40, na.rm = TRUE),
.groups = "drop"
) %>%
arrange(avg_start)
# Create coverage quality score
coverage_stats <- coverage_stats %>%
mutate(
# Lower average start is better for coverage team
coverage_score = 100 * (max(avg_start) - avg_start) /
(max(avg_start) - min(avg_start))
)
# Top coverage teams
coverage_stats %>%
head(10) %>%
select(team, kickoffs, touchback_rate, avg_return,
avg_start, td_allowed, coverage_score) %>%
gt() %>%
cols_label(
team = "Team",
kickoffs = "Kickoffs",
touchback_rate = "TB%",
avg_return = "Avg Return",
avg_start = "Avg Start",
td_allowed = "TDs Allowed",
coverage_score = "Coverage Score"
) %>%
fmt_percent(
columns = touchback_rate,
decimals = 1
) %>%
fmt_number(
columns = c(avg_return, avg_start, coverage_score),
decimals = 1
) %>%
tab_header(
title = "Best Kickoff Coverage Teams - 2023",
subtitle = "Ranked by average starting field position allowed"
) %>%
data_color(
columns = coverage_score,
colors = scales::col_numeric(
palette = c("#d73027", "#ffffbf", "#1a9850"),
domain = c(0, 100)
)
)
#| label: coverage-team-analysis-py
#| message: false
#| warning: false
# Analyze coverage by team
coverage_data = kickoffs.groupby('posteam').agg({
'game_id': 'count',
'touchback': 'mean',
'returned': 'mean',
'start_yardline': 'mean',
'touchdown': 'sum'
}).reset_index()
coverage_data.columns = ['team', 'kickoffs', 'touchback_rate', 'return_rate',
'avg_start', 'td_allowed']
# Calculate average return yards for returns only
avg_returns = kickoffs[kickoffs['returned'] == True].groupby('posteam')['return_yards'].mean()
coverage_data = coverage_data.merge(
avg_returns.rename('avg_return'), left_on='team', right_index=True, how='left'
)
# Count long returns
long_returns = kickoffs[kickoffs['return_yards'] >= 40].groupby('posteam').size()
coverage_data = coverage_data.merge(
long_returns.rename('long_returns'), left_on='team', right_index=True, how='left'
)
coverage_data['long_returns'] = coverage_data['long_returns'].fillna(0)
# Calculate coverage score
coverage_data['coverage_score'] = 100 * (
(coverage_data['avg_start'].max() - coverage_data['avg_start']) /
(coverage_data['avg_start'].max() - coverage_data['avg_start'].min())
)
# Sort by average start (lower is better)
coverage_data = coverage_data.sort_values('avg_start')
print("\nBest Kickoff Coverage Teams - 2023")
print("Ranked by average starting field position allowed")
print("=" * 80)
print(coverage_data.head(10)[['team', 'kickoffs', 'touchback_rate', 'avg_return',
'avg_start', 'td_allowed', 'coverage_score']].to_string(index=False))
Visualizing Coverage Performance
#| label: fig-coverage-scatter-r
#| fig-cap: "Kickoff coverage: touchback rate vs average return yards allowed"
#| fig-width: 10
#| fig-height: 7
#| message: false
#| warning: false
# Create scatter plot of coverage performance
ggplot(coverage_stats, aes(x = touchback_rate, y = avg_return)) +
geom_nfl_logos(aes(team_abbr = team), width = 0.05, alpha = 0.7) +
geom_hline(yintercept = mean(coverage_stats$avg_return, na.rm = TRUE),
linetype = "dashed", color = "gray50") +
geom_vline(xintercept = mean(coverage_stats$touchback_rate, na.rm = TRUE),
linetype = "dashed", color = "gray50") +
scale_x_continuous(labels = scales::percent_format()) +
labs(
title = "Kickoff Coverage Performance by Team",
subtitle = "Lower-left quadrant represents elite coverage (high touchbacks, low return yards)",
x = "Touchback Rate",
y = "Average Return Yards Allowed (on returns)",
caption = "Data: nflfastR | 2023 NFL Season\nLines represent league averages"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(size = 11)
)
#| label: fig-coverage-scatter-py
#| fig-cap: "Kickoff coverage: touchback rate vs average return yards allowed - Python"
#| fig-width: 10
#| fig-height: 7
#| message: false
#| warning: false
# Create scatter plot
fig, ax = plt.subplots(figsize=(10, 7))
# Plot points
ax.scatter(coverage_data['touchback_rate'], coverage_data['avg_return'],
s=100, alpha=0.6, c='#1f77b4')
# Add team labels
for idx, row in coverage_data.iterrows():
ax.annotate(row['team'], (row['touchback_rate'], row['avg_return']),
fontsize=8, ha='center', va='center')
# Add average lines
avg_tb = coverage_data['touchback_rate'].mean()
avg_ret = coverage_data['avg_return'].mean()
ax.axhline(y=avg_ret, color='gray', linestyle='--', alpha=0.7)
ax.axvline(x=avg_tb, color='gray', linestyle='--', alpha=0.7)
ax.set_xlabel('Touchback Rate', fontsize=12)
ax.set_ylabel('Average Return Yards Allowed (on returns)', fontsize=12)
ax.set_title('Kickoff Coverage Performance by Team\nLower-left quadrant represents elite coverage (high touchbacks, low return yards)',
fontsize=14, fontweight='bold')
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:.0%}'))
ax.text(0.98, 0.02, 'Data: nfl_data_py | 2023 NFL Season\nLines represent league averages',
transform=ax.transAxes, ha='right', fontsize=8, style='italic')
plt.tight_layout()
plt.show()
New Kickoff Rules Analysis
Impact of 2024 Rule Changes
The 2024 NFL season introduced significant kickoff rule changes:
- Kickoff alignment: Kickoff team lines up at receiving team's 40-yard line
- Setup zone: Receiving team has setup zone between 35 and 30-yard lines
- Landing zone: Ball must land between goal line and 20-yard line
- Touchback changes: Touchbacks now come out to the 30-yard line (vs 25)
Let's analyze how these rules would affect decision-making:
#| label: new-rules-simulation-r
#| message: false
#| warning: false
# Simulate new rules impact
# Under new rules, touchback = 30-yard line
new_rules_analysis <- kickoffs %>%
mutate(
# New touchback line
new_tb_ep = calculate_ep(30),
old_tb_ep = calculate_ep(25),
# Expected value under new rules
return_ep = calculate_ep(start_yardline),
# Decision analysis
old_rules_return = start_yardline > 25,
new_rules_return = start_yardline > 30,
# Value difference
old_return_value = return_ep - old_tb_ep,
new_return_value = return_ep - new_tb_ep
)
# Compare optimal decisions
rule_comparison <- new_rules_analysis %>%
summarise(
old_rules_should_return = mean(old_rules_return, na.rm = TRUE),
new_rules_should_return = mean(new_rules_return, na.rm = TRUE),
decision_change_pct = mean(old_rules_return != new_rules_return, na.rm = TRUE),
avg_old_value = mean(old_return_value, na.rm = TRUE),
avg_new_value = mean(new_return_value, na.rm = TRUE)
)
# Display comparison
tibble(
Metric = c(
"% of kicks should be returned",
"Average return value (EP)",
"% of decisions that change"
),
`Old Rules (TB@25)` = c(
scales::percent(rule_comparison$old_rules_should_return),
round(rule_comparison$avg_old_value, 2),
"-"
),
`New Rules (TB@30)` = c(
scales::percent(rule_comparison$new_rules_should_return),
round(rule_comparison$avg_new_value, 2),
scales::percent(rule_comparison$decision_change_pct)
)
) %>%
gt() %>%
tab_header(
title = "Impact of New Kickoff Rules on Return Decisions",
subtitle = "Comparison of touchback at 25-yard vs 30-yard line"
) %>%
tab_style(
style = cell_fill(color = "#E8F4F8"),
locations = cells_body(rows = 1)
)
#| label: new-rules-simulation-py
#| message: false
#| warning: false
# Simulate new rules
new_rules_sim = kickoffs.copy()
# Calculate EP under different rules
new_rules_sim['new_tb_ep'] = calculate_ep(30)
new_rules_sim['old_tb_ep'] = calculate_ep(25)
new_rules_sim['return_ep'] = new_rules_sim['start_yardline'].apply(calculate_ep)
# Decision analysis
new_rules_sim['old_rules_return'] = new_rules_sim['start_yardline'] > 25
new_rules_sim['new_rules_return'] = new_rules_sim['start_yardline'] > 30
# Value differences
new_rules_sim['old_return_value'] = new_rules_sim['return_ep'] - new_rules_sim['old_tb_ep']
new_rules_sim['new_return_value'] = new_rules_sim['return_ep'] - new_rules_sim['new_tb_ep']
# Calculate summary
old_should_return = new_rules_sim['old_rules_return'].mean()
new_should_return = new_rules_sim['new_rules_return'].mean()
decision_change = (new_rules_sim['old_rules_return'] != new_rules_sim['new_rules_return']).mean()
avg_old_value = new_rules_sim['old_return_value'].mean()
avg_new_value = new_rules_sim['new_return_value'].mean()
print("\nImpact of New Kickoff Rules on Return Decisions")
print("Comparison of touchback at 25-yard vs 30-yard line")
print("=" * 70)
print(f"% of kicks should be returned (Old): {old_should_return:.1%}")
print(f"% of kicks should be returned (New): {new_should_return:.1%}")
print(f"Average return value - Old: {avg_old_value:.2f} EP")
print(f"Average return value - New: {avg_new_value:.2f} EP")
print(f"% of decisions that change: {decision_change:.1%}")
Expected Return Strategies Under New Rules
#| label: fig-new-rules-strategy-r
#| fig-cap: "Optimal return decisions under old vs new kickoff rules"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
# Analyze by depth into end zone
strategy_by_depth <- new_rules_analysis %>%
filter(kick_into_ez) %>%
mutate(
ez_depth_group = cut(
kick_distance - 100,
breaks = c(0, 2, 4, 6, 8, 10),
labels = c("0-2", "2-4", "4-6", "6-8", "8-10")
)
) %>%
group_by(ez_depth_group) %>%
summarise(
kicks = n(),
old_return_pct = mean(old_rules_return, na.rm = TRUE),
new_return_pct = mean(new_rules_return, na.rm = TRUE),
.groups = "drop"
) %>%
pivot_longer(
cols = c(old_return_pct, new_return_pct),
names_to = "rule_set",
values_to = "return_pct"
) %>%
mutate(
rule_set = ifelse(rule_set == "old_return_pct",
"Old Rules (TB@25)",
"New Rules (TB@30)")
)
ggplot(strategy_by_depth, aes(x = ez_depth_group, y = return_pct,
fill = rule_set)) +
geom_col(position = "dodge", alpha = 0.8) +
geom_text(
aes(label = scales::percent(return_pct, accuracy = 1)),
position = position_dodge(width = 0.9),
vjust = -0.5,
size = 3
) +
scale_y_continuous(labels = scales::percent_format(), limits = c(0, 1)) +
scale_fill_manual(values = c("Old Rules (TB@25)" = "#F8766D",
"New Rules (TB@30)" = "#00BFC4")) +
labs(
title = "Optimal Return Decisions Under Different Rules",
subtitle = "New touchback at 30-yard line reduces optimal return frequency",
x = "Yards Deep into End Zone",
y = "% of Kicks That Should Be Returned",
fill = "Rule Set",
caption = "Data: nflfastR | 2023 NFL Season"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
legend.position = "top"
)
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
#| label: fig-new-rules-strategy-py
#| fig-cap: "Optimal return decisions under old vs new kickoff rules - Python"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
# Filter for kicks into end zone
ez_kicks = new_rules_sim[new_rules_sim['kick_distance'] >= 100].copy()
# Create depth groups
def depth_group(distance):
depth = distance - 100
if depth < 2:
return "0-2"
elif depth < 4:
return "2-4"
elif depth < 6:
return "4-6"
elif depth < 8:
return "6-8"
else:
return "8-10"
ez_kicks['ez_depth_group'] = ez_kicks['kick_distance'].apply(depth_group)
# Calculate return percentages
strategy_summary = []
for depth in ["0-2", "2-4", "4-6", "6-8", "8-10"]:
depth_data = ez_kicks[ez_kicks['ez_depth_group'] == depth]
if len(depth_data) > 0:
strategy_summary.append({
'depth': depth,
'kicks': len(depth_data),
'old_return_pct': depth_data['old_rules_return'].mean(),
'new_return_pct': depth_data['new_rules_return'].mean()
})
strategy_df = pd.DataFrame(strategy_summary)
# Create visualization
fig, ax = plt.subplots(figsize=(10, 6))
x = np.arange(len(strategy_df))
width = 0.35
bars1 = ax.bar(x - width/2, strategy_df['old_return_pct'], width,
label='Old Rules (TB@25)', alpha=0.8, color='#F8766D')
bars2 = ax.bar(x + width/2, strategy_df['new_return_pct'], width,
label='New Rules (TB@30)', alpha=0.8, color='#00BFC4')
# Add value labels
for bars in [bars1, bars2]:
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., height,
f'{height:.0%}', ha='center', va='bottom', fontsize=9)
ax.set_xlabel('Yards Deep into End Zone', fontsize=12)
ax.set_ylabel('% of Kicks That Should Be Returned', fontsize=12)
ax.set_title('Optimal Return Decisions Under Different Rules\nNew touchback at 30-yard line reduces optimal return frequency',
fontsize=14, fontweight='bold')
ax.set_xticks(x)
ax.set_xticklabels(strategy_df['depth'])
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0%}'))
ax.set_ylim(0, 1.0)
ax.legend(title='Rule Set', loc='upper right')
ax.text(0.98, 0.02, 'Data: nfl_data_py | 2023 NFL Season',
transform=ax.transAxes, ha='right', fontsize=8, style='italic')
plt.tight_layout()
plt.show()
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
Return Schemes and Blocking Analysis
Identifying Return Schemes
While play-by-play data doesn't explicitly code return schemes, we can infer patterns:
#| label: return-schemes-r
#| message: false
#| warning: false
# Analyze return patterns by returner
scheme_patterns <- kickoffs %>%
filter(returned == TRUE) %>%
mutate(
direction = case_when(
abs(return_yards - kick_distance) < 5 ~ "Middle",
return_yards > kick_distance + 5 ~ "Right",
return_yards < kick_distance - 5 ~ "Left",
TRUE ~ "Middle"
),
return_quality = case_when(
return_yards >= 40 ~ "Explosive",
return_yards >= 25 ~ "Good",
return_yards >= 15 ~ "Average",
TRUE ~ "Poor"
)
) %>%
group_by(posteam, return_quality) %>%
summarise(
returns = n(),
avg_yards = mean(return_yards, na.rm = TRUE),
.groups = "drop"
)
# Return quality distribution
scheme_patterns %>%
group_by(return_quality) %>%
summarise(
total_returns = sum(returns),
pct_of_returns = sum(returns) / sum(scheme_patterns$returns),
avg_yards = weighted.mean(avg_yards, returns),
.groups = "drop"
) %>%
arrange(desc(pct_of_returns)) %>%
gt() %>%
cols_label(
return_quality = "Return Quality",
total_returns = "Returns",
pct_of_returns = "% of Returns",
avg_yards = "Avg Yards"
) %>%
fmt_percent(
columns = pct_of_returns,
decimals = 1
) %>%
fmt_number(
columns = c(avg_yards),
decimals = 1
) %>%
tab_header(
title = "Kickoff Return Quality Distribution",
subtitle = "2023 NFL Season"
)
#| label: return-schemes-py
#| message: false
#| warning: false
# Classify return quality
def classify_return(yards):
if pd.isna(yards):
return "None"
if yards >= 40:
return "Explosive"
elif yards >= 25:
return "Good"
elif yards >= 15:
return "Average"
else:
return "Poor"
returns_data = kickoffs[kickoffs['returned'] == True].copy()
returns_data['return_quality'] = returns_data['return_yards'].apply(classify_return)
# Analyze patterns
quality_dist = returns_data.groupby('return_quality').agg({
'game_id': 'count',
'return_yards': 'mean'
}).reset_index()
quality_dist.columns = ['return_quality', 'total_returns', 'avg_yards']
quality_dist['pct_of_returns'] = quality_dist['total_returns'] / quality_dist['total_returns'].sum()
# Sort by percentage
quality_order = ['Explosive', 'Good', 'Average', 'Poor']
quality_dist['return_quality'] = pd.Categorical(
quality_dist['return_quality'],
categories=quality_order,
ordered=True
)
quality_dist = quality_dist.sort_values('return_quality')
print("\nKickoff Return Quality Distribution (2023 NFL Season)")
print("=" * 60)
print(quality_dist.to_string(index=False))
Complete Special Teams Unit Ratings
Comprehensive Special Teams Score
Let's create an overall special teams rating that combines all return game components:
#| label: complete-st-ratings-r
#| message: false
#| warning: false
# Combine kickoff and punt data for comprehensive rating
# Kickoff coverage (defending)
ko_coverage <- kickoffs %>%
group_by(team = posteam) %>%
summarise(
ko_cov_start = mean(start_yardline, na.rm = TRUE),
ko_cov_tb_rate = mean(touchback, na.rm = TRUE),
.groups = "drop"
)
# Kickoff return (returning)
ko_return <- kickoffs %>%
group_by(team = defteam) %>%
summarise(
ko_ret_start = mean(start_yardline, na.rm = TRUE),
ko_ret_rate = mean(returned, na.rm = TRUE),
.groups = "drop"
)
# Punt coverage
punt_coverage <- punts %>%
group_by(team = posteam) %>%
summarise(
punt_cov_net = mean(net_yards, na.rm = TRUE),
punt_ret_allowed = mean(returned, na.rm = TRUE),
.groups = "drop"
)
# Punt return
punt_return <- punts %>%
group_by(team = defteam) %>%
summarise(
punt_ret_avg = mean(return_yards[returned == 1], na.rm = TRUE),
punt_ret_rate = mean(returned, na.rm = TRUE),
.groups = "drop"
)
# Combine all metrics
st_ratings <- ko_coverage %>%
left_join(ko_return, by = "team") %>%
left_join(punt_coverage, by = "team") %>%
left_join(punt_return, by = "team") %>%
mutate(
# Normalize each component (0-100 scale)
ko_cov_score = 100 * (ko_cov_start - min(ko_cov_start, na.rm = TRUE)) /
(max(ko_cov_start, na.rm = TRUE) - min(ko_cov_start, na.rm = TRUE)),
ko_ret_score = 100 * (ko_ret_start - min(ko_ret_start, na.rm = TRUE)) /
(max(ko_ret_start, na.rm = TRUE) - min(ko_ret_start, na.rm = TRUE)),
punt_cov_score = 100 * (punt_cov_net - min(punt_cov_net, na.rm = TRUE)) /
(max(punt_cov_net, na.rm = TRUE) - min(punt_cov_net, na.rm = TRUE)),
punt_ret_score = 100 * (punt_ret_avg - min(punt_ret_avg, na.rm = TRUE)) /
(max(punt_ret_avg, na.rm = TRUE) - min(punt_ret_avg, na.rm = TRUE)),
# Overall special teams rating (average of components)
st_rating = (ko_cov_score + ko_ret_score + punt_cov_score + punt_ret_score) / 4
) %>%
arrange(desc(st_rating))
# Display top teams
st_ratings %>%
head(10) %>%
select(team, ko_cov_score, ko_ret_score, punt_cov_score,
punt_ret_score, st_rating) %>%
gt() %>%
cols_label(
team = "Team",
ko_cov_score = "KO Coverage",
ko_ret_score = "KO Return",
punt_cov_score = "Punt Coverage",
punt_ret_score = "Punt Return",
st_rating = "Overall ST Rating"
) %>%
fmt_number(
columns = c(ko_cov_score, ko_ret_score, punt_cov_score,
punt_ret_score, st_rating),
decimals = 1
) %>%
data_color(
columns = st_rating,
colors = scales::col_numeric(
palette = c("#d73027", "#ffffbf", "#1a9850"),
domain = c(0, 100)
)
) %>%
tab_header(
title = "Complete Special Teams Unit Ratings",
subtitle = "2023 NFL Season - Top 10 Teams"
)
#| label: complete-st-ratings-py
#| message: false
#| warning: false
# Kickoff coverage
ko_cov = kickoffs.groupby('posteam').agg({
'start_yardline': 'mean',
'touchback': 'mean'
}).reset_index()
ko_cov.columns = ['team', 'ko_cov_start', 'ko_cov_tb_rate']
# Kickoff return
ko_ret = kickoffs.groupby('defteam').agg({
'start_yardline': 'mean',
'returned': 'mean'
}).reset_index()
ko_ret.columns = ['team', 'ko_ret_start', 'ko_ret_rate']
# Punt coverage
punt_cov = punts.groupby('posteam').agg({
'net_yards': 'mean',
'returned': 'mean'
}).reset_index()
punt_cov.columns = ['team', 'punt_cov_net', 'punt_ret_allowed']
# Punt return
punt_ret_data = punts[punts['returned'] == 1].groupby('defteam')['return_yards'].mean()
punt_ret = punt_ret_data.reset_index()
punt_ret.columns = ['team', 'punt_ret_avg']
# Combine all metrics
st_ratings = ko_cov.merge(ko_ret, on='team', how='outer')
st_ratings = st_ratings.merge(punt_cov, on='team', how='outer')
st_ratings = st_ratings.merge(punt_ret, on='team', how='outer')
# Normalize scores
def normalize(series):
return 100 * (series - series.min()) / (series.max() - series.min())
st_ratings['ko_cov_score'] = normalize(st_ratings['ko_cov_start'])
st_ratings['ko_ret_score'] = normalize(st_ratings['ko_ret_start'])
st_ratings['punt_cov_score'] = normalize(st_ratings['punt_cov_net'])
st_ratings['punt_ret_score'] = normalize(st_ratings['punt_ret_avg'].fillna(0))
# Calculate overall rating
st_ratings['st_rating'] = (
st_ratings['ko_cov_score'] + st_ratings['ko_ret_score'] +
st_ratings['punt_cov_score'] + st_ratings['punt_ret_score']
) / 4
# Sort and display
st_ratings = st_ratings.sort_values('st_rating', ascending=False)
print("\nComplete Special Teams Unit Ratings - Top 10 Teams (2023)")
print("=" * 90)
print(st_ratings.head(10)[['team', 'ko_cov_score', 'ko_ret_score',
'punt_cov_score', 'punt_ret_score',
'st_rating']].to_string(index=False))
Visualizing Special Teams Performance
#| label: fig-st-radar-r
#| fig-cap: "Special teams performance profile for top teams"
#| fig-width: 12
#| fig-height: 8
#| message: false
#| warning: false
# Create radar chart data for top 6 teams
library(ggradar)
top_teams <- st_ratings %>%
head(6) %>%
select(team, ko_cov_score, ko_ret_score, punt_cov_score, punt_ret_score) %>%
mutate(across(where(is.numeric), ~ . / 100)) %>%
rename(
Team = team,
`KO Coverage` = ko_cov_score,
`KO Return` = ko_ret_score,
`Punt Coverage` = punt_cov_score,
`Punt Return` = punt_ret_score
)
# Note: ggradar might not be available, so we'll use a different visualization
# Create faceted bar chart instead
st_ratings %>%
head(10) %>%
select(team, ko_cov_score, ko_ret_score, punt_cov_score, punt_ret_score) %>%
pivot_longer(
cols = -team,
names_to = "metric",
values_to = "score"
) %>%
mutate(
metric = case_when(
metric == "ko_cov_score" ~ "KO Coverage",
metric == "ko_ret_score" ~ "KO Return",
metric == "punt_cov_score" ~ "Punt Coverage",
metric == "punt_ret_score" ~ "Punt Return"
)
) %>%
ggplot(aes(x = metric, y = score, fill = metric)) +
geom_col(alpha = 0.8) +
facet_wrap(~ team, ncol = 5) +
scale_fill_brewer(palette = "Set2") +
coord_flip() +
labs(
title = "Special Teams Performance Profile",
subtitle = "Top 10 Teams - 2023 NFL Season",
x = "",
y = "Score (0-100)",
caption = "Data: nflfastR"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
legend.position = "none",
strip.text = element_text(face = "bold"),
axis.text.y = element_text(size = 8)
)
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
#| label: fig-st-radar-py
#| fig-cap: "Special teams performance profile for top teams - Python"
#| fig-width: 12
#| fig-height: 8
#| message: false
#| warning: false
# Create performance profile visualization
top_10 = st_ratings.head(10).copy()
# Reshape data
plot_data = pd.melt(
top_10,
id_vars=['team'],
value_vars=['ko_cov_score', 'ko_ret_score', 'punt_cov_score', 'punt_ret_score'],
var_name='metric',
value_name='score'
)
# Rename metrics
metric_names = {
'ko_cov_score': 'KO Coverage',
'ko_ret_score': 'KO Return',
'punt_cov_score': 'Punt Coverage',
'punt_ret_score': 'Punt Return'
}
plot_data['metric'] = plot_data['metric'].map(metric_names)
# Create subplots
fig, axes = plt.subplots(2, 5, figsize=(15, 8))
axes = axes.flatten()
colors = ['#66c2a5', '#fc8d62', '#8da0cb', '#e78ac3']
for idx, team in enumerate(top_10['team'].head(10)):
ax = axes[idx]
team_data = plot_data[plot_data['team'] == team]
bars = ax.barh(team_data['metric'], team_data['score'], color=colors, alpha=0.8)
ax.set_xlim(0, 100)
ax.set_title(team, fontweight='bold', fontsize=11)
ax.set_xlabel('')
if idx % 5 != 0:
ax.set_yticklabels([])
else:
ax.tick_params(axis='y', labelsize=9)
fig.suptitle('Special Teams Performance Profile\nTop 10 Teams - 2023 NFL Season',
fontsize=16, fontweight='bold')
fig.text(0.5, 0.02, 'Data: nfl_data_py', ha='center', fontsize=9, style='italic')
plt.tight_layout(rect=[0, 0.03, 1, 0.96])
plt.show()
Summary
Return game analytics provides crucial insights into special teams performance:
Key Takeaways:
- Expected Points Framework: Using EP to evaluate returns provides better insight than yards alone
- Decision Analysis: Optimal return vs touchback/fair catch decisions depend on field position and rule context
- Specialist Evaluation: Elite returners create value through consistent positive EPA and explosive plays
- Coverage Quality: Best coverage units combine high touchback rates with limited return yards allowed
- Rule Impact: New kickoff rules significantly alter optimal return strategy and expected value
- Comprehensive Assessment: Complete special teams ratings should incorporate all return and coverage phases
Analytical Priorities:
- Calculate EPA for all returns relative to alternative outcomes
- Track success rates and explosive play frequency
- Monitor fumble rates and risk-adjusted return value
- Evaluate coverage teams by starting field position allowed
- Assess scheme effectiveness through return quality distribution
- Adapt strategies to rule changes and their EP implications
Exercises
Conceptual Questions
-
Return Decision Framework: Under what circumstances should a team return a kickoff from 8 yards deep in the end zone? Consider expected points, fumble risk, and penalty rates.
-
Rule Change Impact: How do the new 2024 kickoff rules (touchback at 30-yard line) change the risk/reward calculation for returns? Which teams benefit most from these changes?
-
Specialist Value: What makes an elite return specialist? Beyond average yards, what metrics best capture returner value?
Coding Exercises
Exercise 1: Return EPA Analysis
Calculate comprehensive return EPA metrics for all teams: a) Total kickoff return EPA by team b) Average punt return EPA by team c) Combined return EPA (kickoffs + punts) d) Identify teams that excel at returns but struggle on coverage (and vice versa) **Bonus**: Create a quadrant chart showing return EPA vs coverage EPA for all teams.Exercise 2: Optimal Return Decisions
Analyze return decisions from deep in the end zone: a) Calculate the success rate of returns from 5+ yards deep b) Determine the break-even point where returns equal touchback value c) Identify returners who make consistently good/bad decisions d) Build a simple model predicting when returns should be attempted **Data**: Use kickoff distance, returner identity, and game situation.Exercise 3: Coverage Team Rankings
Create comprehensive coverage team rankings: a) Calculate average starting field position allowed (kickoffs and punts) b) Measure "big play" rate (returns of 40+ yards) c) Assess touchback/fair catch rate as coverage success d) Build a composite coverage quality score **Visualization**: Create a scatter plot of kickoff vs punt coverage quality.Exercise 4: Return Specialist Career Analysis
Track return specialist performance over multiple seasons: a) Load 3-5 years of data b) Calculate year-by-year EPA for top returners c) Identify aging curves for return specialists d) Compare kickoff vs punt return specialist longevity **Analysis**: Do elite returners maintain performance over time, or is return value concentrated in young players?Exercise 5: New Rules Simulation
Simulate the impact of 2024 kickoff rules: a) Apply new touchback line (30-yard) to historical data b) Calculate how many additional returns would have occurred c) Estimate the change in expected starting field position d) Project how this affects overall game strategy **Extension**: Consider safety implications by analyzing collision rates under different rules.Further Reading
- Return Game Strategy:
- Baldwin, B. (2020). "The Value of the Return Game." Open Source Football.
-
Yurko, R., Ventura, S., & Horowitz, M. (2019). "nflWAR: A reproducible method for offensive player evaluation in football." Journal of Quantitative Analysis in Sports, 15(3), 163-183.
-
Special Teams Analysis:
- Burke, B. (2019). "Measuring Special Teams Value." ESPN Analytics.
-
Lopez, M. (2018). "Bigger data, better questions, and a return to fourth down behavior." The Hardball Times.
-
Rule Changes and Strategy:
- NFL Competition Committee. (2024). "2024 Kickoff Rule Changes: Rationale and Expected Impact."
-
Alamar, B. (2013). Sports Analytics: A Guide for Coaches, Managers, and Other Decision Makers. Columbia University Press.
-
Advanced Metrics:
- Schatz, A. (2020). "Special Teams DVOA Methodology." Football Outsiders.
- Morris, B. (2017). "The Hidden Value of Special Teams." FiveThirtyEight.
References
:::