4 min read

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+...

Chapter 11: Special Teams Analytics

Learning Objectives

By the end of this chapter, you will be able to:

  1. Calculate and interpret EPA-based kicking metrics
  2. Evaluate punters using field position and expected points
  3. Analyze kick and punt return efficiency
  4. Understand the value of field position in special teams
  5. Apply expected value frameworks to special teams decisions
  6. Build comprehensive special teams evaluation systems
  7. 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

  1. EPA is the best framework for special teams evaluation
  2. Field position is the fundamental currency of special teams
  3. Small samples create high variance - be cautious
  4. Context matters - distance, situation, environment all affect performance
  5. 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

  1. Sample sizes are too small for confident individual evaluation
  2. Weather/environment effects are difficult to model
  3. Scheme and unit coordination not captured in individual stats
  4. Year-to-year stability is moderate at best
  5. 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.