Special teams often receives minimal analytical attention despite comprising approximately 17% of plays and having a disproportionate impact on close games. A single blocked punt, missed field goal, or return touchdown can swing outcomes by 7+...
In This Chapter
Chapter 11: Special Teams Analytics
Learning Objectives
By the end of this chapter, you will be able to:
- Calculate and interpret EPA-based kicking metrics
- Evaluate punters using field position and expected points
- Analyze kick and punt return efficiency
- Understand the value of field position in special teams
- Apply expected value frameworks to special teams decisions
- Build comprehensive special teams evaluation systems
- Recognize the limitations and variance in special teams metrics
Introduction: The Overlooked Third Phase
Special teams often receives minimal analytical attention despite comprising approximately 17% of plays and having a disproportionate impact on close games. A single blocked punt, missed field goal, or return touchdown can swing outcomes by 7+ points - yet many teams still underinvest in this phase.
Why Special Teams Matter
Hidden Points
Consider this breakdown of point generation: - Offense: ~60% of scoring - Defense (turnovers leading to scores): ~25% - Special teams: ~15% (but with high variance)
However, the 15% from special teams often occurs in high-leverage situations where points matter most.
Field Position Value
Every 10 yards of starting position is worth approximately 0.4 expected points. A punt that pins opponents at the 10 vs the 30 represents a 0.8 EP swing - the equivalent of a successful medium passing play.
Close Game Impact
In games decided by 7 or fewer points: - 78% feature a special teams play worth >3 points - 45% have a special teams play as the decisive factor - Kicker performance is often the difference
Kicker Evaluation
Traditional Metrics and Their Limitations
Field Goal Percentage
Raw FG% is the most common metric but has significant flaws:
import nfl_data_py as nfl
import pandas as pd
import numpy as np
# Load data
pbp = nfl.import_pbp_data([2023])
# Filter to field goal attempts
fgs = pbp[pbp['field_goal_attempt'] == 1]
# Simple FG percentage
kicker_simple = (fgs
.groupby('kicker_player_name')
.agg(
attempts=('field_goal_attempt', 'count'),
makes=('field_goal_result', lambda x: (x == 'made').sum())
)
.query('attempts >= 15')
)
kicker_simple['fg_pct'] = kicker_simple['makes'] / kicker_simple['attempts']
print("Simple FG%:")
print(kicker_simple.sort_values('fg_pct', ascending=False).head(10).round(3).to_string())
Problems with Raw FG%: 1. Doesn't account for distance (40-yarder vs 50-yarder) 2. Ignores environmental conditions (dome vs outdoor) 3. Small samples (25-35 attempts per season) 4. Doesn't capture value (chip shot vs game-winner)
Expected Field Goal Percentage
Better evaluation compares actual performance to expected:
def expected_fg_pct(distance: float) -> float:
"""
Calculate expected FG% based on distance.
Based on historical league data.
"""
if distance <= 20:
return 0.99
elif distance <= 30:
return 0.95
elif distance <= 40:
return 0.88
elif distance <= 50:
return 0.75
elif distance <= 55:
return 0.60
else:
return 0.45
# Apply expected FG%
fgs['expected_make'] = fgs['kick_distance'].apply(expected_fg_pct)
fgs['made'] = (fgs['field_goal_result'] == 'made').astype(int)
# Calculate FG over expected
kicker_analysis = (fgs
.groupby('kicker_player_name')
.agg(
attempts=('field_goal_attempt', 'count'),
makes=('made', 'sum'),
expected_makes=('expected_make', 'sum'),
avg_distance=('kick_distance', 'mean')
)
.query('attempts >= 15')
)
kicker_analysis['actual_pct'] = kicker_analysis['makes'] / kicker_analysis['attempts']
kicker_analysis['expected_pct'] = kicker_analysis['expected_makes'] / kicker_analysis['attempts']
kicker_analysis['fg_over_expected'] = kicker_analysis['makes'] - kicker_analysis['expected_makes']
print("\nFG Over Expected:")
print(kicker_analysis.sort_values('fg_over_expected', ascending=False).head(10).round(2).to_string())
EPA-Based Kicker Evaluation
The most comprehensive approach uses EPA:
def kicker_epa_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Evaluate kickers using EPA framework.
"""
fgs = pbp[pbp['field_goal_attempt'] == 1].copy()
# EPA already calculated in PBP
kicker_epa = (fgs
.groupby('kicker_player_name')
.agg(
attempts=('field_goal_attempt', 'count'),
makes=('field_goal_result', lambda x: (x == 'made').sum()),
total_epa=('epa', 'sum'),
epa_per_attempt=('epa', 'mean'),
avg_distance=('kick_distance', 'mean')
)
.query('attempts >= 15')
.sort_values('total_epa', ascending=False)
)
return kicker_epa
kicker_epa = kicker_epa_analysis(pbp)
print("\nKicker EPA Rankings:")
print(kicker_epa.round(2).to_string())
Extra Point Analysis
Since the extra point distance change (to 33 yards) in 2015, XP conversion matters:
def xp_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Analyze extra point performance.
"""
xps = pbp[pbp['extra_point_attempt'] == 1]
xp_stats = (xps
.groupby('kicker_player_name')
.agg(
xp_attempts=('extra_point_attempt', 'count'),
xp_makes=('extra_point_result', lambda x: (x == 'good').sum()),
xp_epa=('epa', 'sum')
)
.query('xp_attempts >= 20')
)
xp_stats['xp_pct'] = xp_stats['xp_makes'] / xp_stats['xp_attempts']
# League average for context
league_xp_pct = xps['extra_point_result'].apply(lambda x: x == 'good').mean()
xp_stats['xp_vs_avg'] = xp_stats['xp_pct'] - league_xp_pct
return xp_stats
xp_stats = xp_analysis(pbp)
print("\nExtra Point Analysis:")
print(xp_stats.round(3).to_string())
Distance-Adjusted Evaluation
def distance_adjusted_kicker(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Create distance-adjusted kicker metrics.
"""
fgs = pbp[pbp['field_goal_attempt'] == 1].copy()
# Create distance buckets
fgs['distance_bucket'] = pd.cut(
fgs['kick_distance'],
bins=[0, 30, 40, 50, 70],
labels=['short', 'medium', 'long', 'very_long']
)
# Calculate by bucket
bucket_analysis = (fgs
.groupby(['kicker_player_name', 'distance_bucket'])
.agg(
attempts=('field_goal_attempt', 'count'),
makes=('field_goal_result', lambda x: (x == 'made').sum())
)
.reset_index()
)
bucket_analysis['pct'] = bucket_analysis['makes'] / bucket_analysis['attempts']
# Pivot for comparison
pivot = bucket_analysis.pivot(
index='kicker_player_name',
columns='distance_bucket',
values='pct'
)
return pivot
distance_analysis = distance_adjusted_kicker(pbp)
print("\nFG% by Distance:")
print(distance_analysis.round(2).to_string())
Punter Evaluation
Traditional Punting Metrics
Gross Average
Gross average (total yards / punts) is common but flawed: - Doesn't account for situation (own 10 vs opponent's 40) - Ignores net (return yards matter) - Rewards touchbacks in some situations
Net Average
Net average (gross - return yards) is better but still limited: - Doesn't capture hangtime/coverage interaction - Ignores field position context
Expected Punt Value
The best approach considers field position change:
def punt_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Analyze punting using field position framework.
"""
punts = pbp[pbp['punt_attempt'] == 1].copy()
# Calculate net yards
punts['net_yards'] = punts['kick_distance'] - punts['return_yards'].fillna(0)
# Starting position affects expectations
punts['starting_position'] = 100 - punts['yardline_100']
# Inside 20 rate
punts['pinned'] = punts['yardline_100'] - punts['kick_distance'] <= 20
punt_stats = (punts
.groupby('punter_player_name')
.agg(
punts=('punt_attempt', 'count'),
gross_avg=('kick_distance', 'mean'),
net_avg=('net_yards', 'mean'),
inside_20_rate=('pinned', 'mean'),
touchback_rate=('touchback', 'mean'),
total_epa=('epa', 'sum'),
epa_per_punt=('epa', 'mean')
)
.query('punts >= 20')
.sort_values('epa_per_punt', ascending=False)
)
return punt_stats
punt_stats = punt_analysis(pbp)
print("Punter Rankings (by EPA):")
print(punt_stats.round(2).to_string())
Situational Punting
Different situations demand different approaches:
def situational_punt_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Analyze punting by field position situation.
"""
punts = pbp[pbp['punt_attempt'] == 1].copy()
# Categorize situations
punts['situation'] = pd.cut(
punts['yardline_100'],
bins=[0, 40, 55, 70, 100],
labels=['deep', 'midfield', 'plus_territory', 'backed_up']
)
# Analyze by punter and situation
situation_analysis = (punts
.groupby(['punter_player_name', 'situation'])
.agg(
punts=('punt_attempt', 'count'),
net_avg=('kick_distance', 'mean'),
inside_20=('pinned', 'mean') if 'pinned' in punts.columns else ('punt_attempt', 'count'),
epa=('epa', 'mean')
)
.query('punts >= 5')
.reset_index()
)
return situation_analysis
Hangtime and Coverage Interaction
Without tracking data, we can infer hangtime from return outcomes:
def coverage_quality(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Estimate punt coverage quality from outcomes.
"""
punts = pbp[pbp['punt_attempt'] == 1].copy()
# Fair catches indicate good hangtime/coverage
# Long returns indicate poor hangtime/coverage
punts['fair_catch'] = punts['punt_fair_catch'].fillna(0)
punts['long_return'] = punts['return_yards'].fillna(0) >= 15
coverage = (punts
.groupby('punt_team')
.agg(
punts=('punt_attempt', 'count'),
fair_catch_rate=('fair_catch', 'mean'),
long_return_rate=('long_return', 'mean'),
avg_return=('return_yards', 'mean'),
net_yards=('kick_distance', lambda x: (x - punts.loc[x.index, 'return_yards'].fillna(0)).mean())
)
)
# Coverage quality score
coverage['coverage_score'] = (
coverage['fair_catch_rate'] * 100 -
coverage['long_return_rate'] * 100 -
coverage['avg_return'] * 2
)
return coverage.sort_values('coverage_score', ascending=False)
Return Game Analysis
Kick Return Evaluation
def kick_return_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Analyze kick returners.
"""
# Filter to kickoff returns
returns = pbp[
(pbp['kickoff_attempt'] == 1) &
(pbp['return_yards'].notna()) &
(pbp['return_yards'] > 0)
].copy()
returner_stats = (returns
.groupby('kickoff_returner_player_name')
.agg(
returns=('return_yards', 'count'),
total_yards=('return_yards', 'sum'),
avg_return=('return_yards', 'mean'),
long_return=('return_yards', 'max'),
touchdowns=('return_touchdown', 'sum'),
epa_total=('epa', 'sum'),
epa_per_return=('epa', 'mean')
)
.query('returns >= 10')
.sort_values('epa_per_return', ascending=False)
)
# Starting position value
returner_stats['field_position_value'] = returner_stats['avg_return'] * 0.04 # ~0.04 EP per yard
return returner_stats
kr_stats = kick_return_analysis(pbp)
print("Kick Returner Rankings:")
print(kr_stats.round(2).to_string())
Punt Return Evaluation
def punt_return_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Analyze punt returners.
"""
returns = pbp[
(pbp['punt_attempt'] == 1) &
(pbp['return_yards'].notna())
].copy()
# Fair catches are relevant too
returns['returned'] = returns['return_yards'] > 0
returner_stats = (returns
.groupby('punt_returner_player_name')
.agg(
opportunities=('punt_attempt', 'count'),
returns=('returned', 'sum'),
total_yards=('return_yards', 'sum'),
avg_return=('return_yards', 'mean'),
touchdowns=('return_touchdown', 'sum'),
fumbles=('fumble', 'sum'),
epa_total=('epa', 'sum'),
epa_per_opp=('epa', 'mean')
)
.query('opportunities >= 10')
.sort_values('epa_per_opp', ascending=False)
)
return returner_stats
pr_stats = punt_return_analysis(pbp)
print("Punt Returner Rankings:")
print(pr_stats.round(2).to_string())
Return Yards Over Expected
Similar to other metrics, we can calculate expected return value:
def return_over_expected(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Calculate return yards over expected based on starting position.
"""
returns = pbp[
(pbp['kickoff_attempt'] == 1) &
(pbp['return_yards'].notna()) &
(pbp['return_yards'] > 0)
].copy()
# Average return by starting position (catch point)
# Approximate from end zone as baseline
league_avg_return = returns['return_yards'].mean()
returner_analysis = (returns
.groupby('kickoff_returner_player_name')
.agg(
returns=('return_yards', 'count'),
total_yards=('return_yards', 'sum'),
avg_return=('return_yards', 'mean')
)
.query('returns >= 10')
)
returner_analysis['expected_yards'] = returner_analysis['returns'] * league_avg_return
returner_analysis['yards_over_expected'] = (
returner_analysis['total_yards'] - returner_analysis['expected_yards']
)
return returner_analysis.sort_values('yards_over_expected', ascending=False)
Kickoff Analysis
Touchback Strategy
Modern NFL strategy emphasizes touchbacks due to rule changes:
def kickoff_strategy_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Analyze kickoff touchback strategy.
"""
kickoffs = pbp[pbp['kickoff_attempt'] == 1].copy()
# Team kickoff strategy
kick_strategy = (kickoffs
.groupby('posteam')
.agg(
kickoffs=('kickoff_attempt', 'count'),
touchbacks=('touchback', 'sum'),
returns=('return_yards', lambda x: (x > 0).sum()),
avg_return_against=('return_yards', lambda x: x[x > 0].mean()),
return_tds_allowed=('return_touchdown', 'sum')
)
)
kick_strategy['touchback_rate'] = kick_strategy['touchbacks'] / kick_strategy['kickoffs']
# Expected starting position
kick_strategy['exp_start_if_tb'] = 25 # Touchback = 25 yard line
kick_strategy['exp_start_if_return'] = 25 + kick_strategy['avg_return_against']
return kick_strategy
ko_strategy = kickoff_strategy_analysis(pbp)
print("Kickoff Strategy:")
print(ko_strategy.round(2).to_string())
Kicker Distance and Hangtime
def kickoff_quality(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Evaluate kickoff quality by kicker.
"""
kickoffs = pbp[pbp['kickoff_attempt'] == 1].copy()
kicker_ko = (kickoffs
.groupby('kicker_player_name')
.agg(
kickoffs=('kickoff_attempt', 'count'),
touchback_rate=('touchback', 'mean'),
out_of_bounds=('kickoff_out_of_bounds', 'mean') if 'kickoff_out_of_bounds' in kickoffs.columns else ('kickoff_attempt', lambda x: 0),
avg_return_allowed=('return_yards', lambda x: x[x > 0].mean() if (x > 0).any() else 0),
return_td_rate=('return_touchdown', 'mean')
)
.query('kickoffs >= 20')
)
# Quality score (higher touchback rate, lower returns)
kicker_ko['quality_score'] = (
kicker_ko['touchback_rate'] * 100 -
kicker_ko['avg_return_allowed'] * 2 -
kicker_ko['return_td_rate'] * 700
)
return kicker_ko.sort_values('quality_score', ascending=False)
Team Special Teams Evaluation
Comprehensive Team Metrics
from dataclasses import dataclass
from typing import List
@dataclass
class TeamSpecialTeamsReport:
"""Complete special teams evaluation."""
team: str
season: int
# Kicking
fg_pct: float
fg_over_expected: float
xp_pct: float
# Punting
punt_net_avg: float
punt_inside_20_rate: float
punt_epa: float
# Returns
kr_avg: float
pr_avg: float
return_tds: int
# Coverage
kr_allowed_avg: float
pr_allowed_avg: float
return_tds_allowed: int
# Overall
st_epa: float
st_rank: int
strengths: List[str]
weaknesses: List[str]
class SpecialTeamsEvaluator:
"""Comprehensive special teams evaluation system."""
def __init__(self, pbp: pd.DataFrame, season: int = 2023):
self.pbp = pbp
self.season = season
# Pre-filter plays
self.fgs = pbp[pbp['field_goal_attempt'] == 1].copy()
self.xps = pbp[pbp['extra_point_attempt'] == 1].copy()
self.punts = pbp[pbp['punt_attempt'] == 1].copy()
self.kickoffs = pbp[pbp['kickoff_attempt'] == 1].copy()
self._calculate_league_averages()
def _calculate_league_averages(self):
"""Calculate league averages for comparison."""
self.league_fg_pct = (self.fgs['field_goal_result'] == 'made').mean()
self.league_xp_pct = (self.xps['extra_point_result'] == 'good').mean()
self.league_punt_net = (self.punts['kick_distance'] - self.punts['return_yards'].fillna(0)).mean()
def evaluate_team(self, team: str) -> TeamSpecialTeamsReport:
"""Evaluate a team's complete special teams."""
# Kicking (team's kicker)
team_fgs = self.fgs[self.fgs['posteam'] == team]
team_xps = self.xps[self.xps['posteam'] == team]
fg_pct = (team_fgs['field_goal_result'] == 'made').mean() if len(team_fgs) > 0 else 0
xp_pct = (team_xps['extra_point_result'] == 'good').mean() if len(team_xps) > 0 else 0
# FG over expected
if len(team_fgs) > 0:
team_fgs['expected'] = team_fgs['kick_distance'].apply(
lambda d: 0.95 if d <= 30 else 0.88 if d <= 40 else 0.75 if d <= 50 else 0.55
)
fg_over_exp = (team_fgs['field_goal_result'] == 'made').sum() - team_fgs['expected'].sum()
else:
fg_over_exp = 0
# Punting (team's punter)
team_punts = self.punts[self.punts['posteam'] == team]
punt_net = (team_punts['kick_distance'] - team_punts['return_yards'].fillna(0)).mean() if len(team_punts) > 0 else 0
punt_inside_20 = ((100 - team_punts['yardline_100'] + team_punts['kick_distance']) <= 20).mean() if len(team_punts) > 0 else 0
punt_epa = team_punts['epa'].sum() if len(team_punts) > 0 else 0
# Kick returns (team returning)
team_kr = self.kickoffs[self.kickoffs['return_team'] == team] if 'return_team' in self.kickoffs.columns else self.kickoffs[self.kickoffs['defteam'] == team]
kr_avg = team_kr['return_yards'].mean() if len(team_kr) > 0 else 0
# Punt returns (team returning)
team_pr = self.punts[self.punts['return_team'] == team] if 'return_team' in self.punts.columns else self.punts[self.punts['defteam'] == team]
pr_avg = team_pr['return_yards'].mean() if len(team_pr) > 0 else 0
# Return TDs
return_tds = 0 # Would need to track this
# Coverage (opponents returning)
opp_kr = self.kickoffs[self.kickoffs['posteam'] == team]
kr_allowed = opp_kr['return_yards'].mean() if len(opp_kr) > 0 else 0
opp_pr = self.punts[self.punts['posteam'] == team]
pr_allowed = opp_pr['return_yards'].mean() if len(opp_pr) > 0 else 0
# Overall ST EPA
all_st_plays = self.pbp[
(self.pbp['posteam'] == team) &
(self.pbp['play_type'].isin(['field_goal', 'extra_point', 'punt', 'kickoff']))
]
st_epa = all_st_plays['epa'].sum() if len(all_st_plays) > 0 else 0
# Calculate rank
all_teams_st_epa = {}
for t in self.pbp['posteam'].unique():
t_plays = self.pbp[
(self.pbp['posteam'] == t) &
(self.pbp['play_type'].isin(['field_goal', 'extra_point', 'punt', 'kickoff']))
]
all_teams_st_epa[t] = t_plays['epa'].sum() if len(t_plays) > 0 else 0
sorted_teams = sorted(all_teams_st_epa.items(), key=lambda x: x[1], reverse=True)
st_rank = [i for i, (t, _) in enumerate(sorted_teams, 1) if t == team][0] if team in dict(sorted_teams) else 16
# Identify strengths and weaknesses
strengths = []
weaknesses = []
if fg_pct > self.league_fg_pct + 0.05:
strengths.append("Accurate kicker")
elif fg_pct < self.league_fg_pct - 0.05:
weaknesses.append("Below average kicking")
if punt_net > self.league_punt_net + 2:
strengths.append("Strong punting")
elif punt_net < self.league_punt_net - 2:
weaknesses.append("Below average punting")
if kr_avg > 25:
strengths.append("Dangerous kick returns")
if pr_avg > 12:
strengths.append("Explosive punt returns")
return TeamSpecialTeamsReport(
team=team,
season=self.season,
fg_pct=fg_pct,
fg_over_expected=fg_over_exp,
xp_pct=xp_pct,
punt_net_avg=punt_net,
punt_inside_20_rate=punt_inside_20,
punt_epa=punt_epa,
kr_avg=kr_avg,
pr_avg=pr_avg,
return_tds=return_tds,
kr_allowed_avg=kr_allowed,
pr_allowed_avg=pr_allowed,
return_tds_allowed=0,
st_epa=st_epa,
st_rank=st_rank,
strengths=strengths,
weaknesses=weaknesses
)
def generate_report(self, team: str) -> str:
"""Generate text report for team."""
r = self.evaluate_team(team)
lines = [
f"\n{'='*60}",
f"SPECIAL TEAMS EVALUATION: {team}",
f"Season: {self.season}",
f"{'='*60}",
"",
f"OVERALL: Rank #{r.st_rank} | Total EPA: {r.st_epa:.1f}",
"",
"KICKING",
"-" * 40,
f" FG%: {r.fg_pct:.1%} (Lg: {self.league_fg_pct:.1%})",
f" FG Over Expected: {r.fg_over_expected:+.1f}",
f" XP%: {r.xp_pct:.1%}",
"",
"PUNTING",
"-" * 40,
f" Net Average: {r.punt_net_avg:.1f} (Lg: {self.league_punt_net:.1f})",
f" Inside 20 Rate: {r.punt_inside_20_rate:.1%}",
f" Punt EPA: {r.punt_epa:.1f}",
"",
"RETURNS",
"-" * 40,
f" KR Average: {r.kr_avg:.1f}",
f" PR Average: {r.pr_avg:.1f}",
f" Return TDs: {r.return_tds}",
"",
"COVERAGE",
"-" * 40,
f" KR Allowed Avg: {r.kr_allowed_avg:.1f}",
f" PR Allowed Avg: {r.pr_allowed_avg:.1f}",
"",
"ASSESSMENT",
"-" * 40,
f" Strengths: {', '.join(r.strengths) if r.strengths else 'None identified'}",
f" Weaknesses: {', '.join(r.weaknesses) if r.weaknesses else 'None identified'}",
f"{'='*60}"
]
return "\n".join(lines)
Decision Analysis
When to Kick Field Goals
Using expected value to evaluate FG decisions:
def fg_decision_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Analyze when teams should attempt field goals.
"""
# 4th down plays
fourth_down = pbp[pbp['down'] == 4].copy()
# Group by distance and analyze outcomes
decisions = fourth_down.groupby(['yardline_100', 'play_type']).agg(
plays=('play_id', 'count'),
success_rate=('epa', lambda x: (x > 0).mean()),
avg_epa=('epa', 'mean')
).reset_index()
# Compare FG attempt vs go for it
return decisions
Punt vs Go For It
def punt_decision_value(yardline: int, ytg: int) -> dict:
"""
Calculate expected value of punt vs going for it.
Args:
yardline: Yards from opponent's end zone
ytg: Yards to go for first down
Returns:
Dictionary with expected values for each decision
"""
# Approximate conversion rates by distance
conversion_prob = max(0.1, 0.75 - ytg * 0.05)
# Field position values
current_ep = -0.03 * yardline + 2.5 # Approximate EP curve
# If convert: continue drive
convert_value = 2.5 # Approximate EP after conversion
# If fail: opponent gets ball at spot
fail_value = -(-0.03 * yardline + 2.5) # Negative of opponent's EP
# Go for it EV
go_ev = conversion_prob * convert_value + (1 - conversion_prob) * fail_value
# Punt value (assume 40 net yards)
punt_net = min(40, yardline - 10) # Can't punt into end zone ideally
new_position = yardline - punt_net
punt_value = -(-0.03 * new_position + 2.5) # Negative of opponent's EP
return {
'yardline': yardline,
'ytg': ytg,
'go_for_it_ev': go_ev,
'punt_ev': punt_value,
'recommended': 'go' if go_ev > punt_value else 'punt',
'ev_difference': go_ev - punt_value
}
# Example analysis
for yl in [50, 60, 70]:
for ytg in [1, 3, 5]:
result = punt_decision_value(yl, ytg)
print(f"YL {yl}, {ytg} to go: {result['recommended']} (diff: {result['ev_difference']:.2f})")
Variance and Sample Size
The Challenge of Small Samples
Special teams statistics are inherently noisy:
def sample_size_analysis():
"""
Demonstrate sample size issues in ST metrics.
"""
# Typical season samples
samples = {
'Field Goals': 30,
'Extra Points': 45,
'Punts': 60,
'Kick Returns': 25,
'Punt Returns': 30
}
# Calculate confidence intervals assuming binomial
for play_type, n in samples.items():
# Assume 75% success rate for demonstration
p = 0.75
se = np.sqrt(p * (1-p) / n)
ci_width = 1.96 * se * 2 # 95% CI width
print(f"{play_type}: n={n}, 95% CI width = {ci_width:.1%}")
print("\nKey insight: Small samples mean wide confidence intervals")
print("A kicker going 24/30 could truly be anywhere from 65% to 95%")
sample_size_analysis()
Year-to-Year Stability
def kicker_stability(years: list = [2022, 2023]) -> float:
"""
Test year-to-year kicker performance correlation.
"""
pbp = nfl.import_pbp_data(years)
yearly_fg = {}
for year in years:
year_fgs = pbp[(pbp['field_goal_attempt'] == 1) & (pbp['season'] == year)]
kicker_pct = (year_fgs
.groupby('kicker_player_name')
.agg(
attempts=('field_goal_attempt', 'count'),
pct=('field_goal_result', lambda x: (x == 'made').mean())
)
.query('attempts >= 15')
)
yearly_fg[year] = kicker_pct['pct']
# Correlation between years
common = yearly_fg[years[0]].index.intersection(yearly_fg[years[1]].index)
if len(common) > 5:
corr = yearly_fg[years[0]][common].corr(yearly_fg[years[1]][common])
return corr
return None
# Typically r ~ 0.30-0.40 for kickers
Summary
Key Concepts
- EPA is the best framework for special teams evaluation
- Field position is the fundamental currency of special teams
- Small samples create high variance - be cautious
- Context matters - distance, situation, environment all affect performance
- Over expected metrics better isolate skill from circumstance
Metric Summary
| Category | Key Metric | What It Measures |
|---|---|---|
| Kicking | FG Over Expected | Accuracy vs difficulty |
| Punting | Net + Inside 20 | Field position value |
| Returns | Yards Over Expected | Return skill vs opportunity |
| Coverage | Return yards allowed | Unit coordination |
| Overall | Total ST EPA | Complete contribution |
Limitations
- Sample sizes are too small for confident individual evaluation
- Weather/environment effects are difficult to model
- Scheme and unit coordination not captured in individual stats
- Year-to-year stability is moderate at best
- High-leverage context hard to incorporate
Preview: Part 3
With player evaluation complete, Part 3 shifts to Team Analytics - examining how individual performances combine into team success, efficiency metrics, and what drives winning.