3 min read

Special teams may only account for 15-20% of plays in a football game, but their impact extends far beyond that proportion. A single blocked punt, a clutch field goal, or a momentum-shifting kickoff return can determine the outcome of a game. Yet...

Chapter 10: Special Teams Analytics

Introduction

Special teams may only account for 15-20% of plays in a football game, but their impact extends far beyond that proportion. A single blocked punt, a clutch field goal, or a momentum-shifting kickoff return can determine the outcome of a game. Yet special teams remain the most underanalyzed phase of football.

This chapter explores comprehensive special teams analytics, from field goal probability models to punt decision analysis. You'll learn to evaluate kickers, punters, returners, and coverage units using data-driven methods that go beyond simple percentage statistics.

Learning Objectives

After completing this chapter, you will be able to:

  1. Build and apply field goal probability models
  2. Calculate expected points from punting decisions
  3. Analyze kickoff and punt return effectiveness
  4. Evaluate coverage unit performance
  5. Make data-driven fourth down decisions
  6. Build comprehensive special teams rating systems

10.1 Field Goal Analytics

Field Goal Probability Models

The probability of making a field goal depends primarily on distance, but other factors also play a role.

import pandas as pd
import numpy as np
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass
from scipy.optimize import curve_fit

class FieldGoalProbabilityModel:
    """
    Model field goal probability based on distance and conditions.

    Primary factors:
    - Distance (strongest predictor)
    - Weather (wind, precipitation)
    - Surface (grass vs. turf)
    - Game situation (pressure kicks)
    - Kicker quality
    """

    def __init__(self):
        # Baseline probabilities by distance (based on historical data)
        # These are approximate NFL/FBS averages
        self.baseline_probs = {
            20: 0.98, 25: 0.97, 30: 0.95, 35: 0.92,
            40: 0.87, 45: 0.80, 50: 0.72, 55: 0.60,
            60: 0.45, 65: 0.30
        }

        # Weather adjustments (multipliers)
        self.weather_adj = {
            'dome': 1.02,
            'clear': 1.00,
            'wind_light': 0.97,
            'wind_moderate': 0.93,
            'wind_heavy': 0.85,
            'rain': 0.90,
            'snow': 0.82
        }

        # Surface adjustments
        self.surface_adj = {
            'turf': 1.01,
            'grass': 1.00,
            'grass_wet': 0.96
        }

    def predict_probability(self, distance: int,
                           weather: str = 'clear',
                           surface: str = 'grass',
                           kicker_skill: float = 1.0) -> float:
        """
        Predict field goal probability.

        Parameters:
        -----------
        distance : int
            Field goal distance in yards
        weather : str
            Weather condition
        surface : str
            Playing surface type
        kicker_skill : float
            Kicker adjustment (1.0 = average, >1 = above avg)

        Returns:
        --------
        float : Probability of making the kick (0-1)
        """
        # Get baseline probability
        if distance < 20:
            base_prob = 0.99
        elif distance > 65:
            base_prob = 0.20
        else:
            # Interpolate between known points
            lower = (distance // 5) * 5
            upper = lower + 5
            lower_prob = self.baseline_probs.get(lower, 0.50)
            upper_prob = self.baseline_probs.get(upper, 0.40)

            # Linear interpolation
            ratio = (distance - lower) / 5
            base_prob = lower_prob + ratio * (upper_prob - lower_prob)

        # Apply adjustments
        prob = base_prob
        prob *= self.weather_adj.get(weather, 1.0)
        prob *= self.surface_adj.get(surface, 1.0)
        prob *= kicker_skill

        # Ensure valid probability
        return max(0.01, min(0.99, prob))

    def expected_points_fg_attempt(self, distance: int,
                                    **kwargs) -> float:
        """
        Calculate expected points from a field goal attempt.

        EP(FG) = P(make) * 3 + P(miss) * EP(opponent at FG spot)
        """
        prob = self.predict_probability(distance, **kwargs)

        # If missed, opponent gets ball at line of scrimmage (roughly)
        # EP for opponent at own 20-30 is typically around 0.5-1.0
        opponent_ep_if_miss = 0.8  # Simplified

        expected_points = prob * 3 + (1 - prob) * (-opponent_ep_if_miss)

        return round(expected_points, 2)


# Example usage
print("=" * 70)
print("FIELD GOAL PROBABILITY MODEL")
print("=" * 70)

fg_model = FieldGoalProbabilityModel()

print("\nBaseline Probabilities by Distance:")
print("-" * 40)
for distance in range(20, 66, 5):
    prob = fg_model.predict_probability(distance)
    ep = fg_model.expected_points_fg_attempt(distance)
    print(f"  {distance} yards: {prob*100:.1f}% (EP: {ep:+.2f})")

print("\nWeather Impact on 45-yard FG:")
print("-" * 40)
for weather in ['dome', 'clear', 'wind_moderate', 'rain', 'snow']:
    prob = fg_model.predict_probability(45, weather=weather)
    print(f"  {weather}: {prob*100:.1f}%")

Kicker Evaluation

Evaluating kickers requires adjusting for distance distribution and conditions.

class KickerEvaluator:
    """
    Evaluate kickers using expected vs. actual performance.

    Key metrics:
    - FG% vs. expected FG%
    - FG+ (performance relative to expected)
    - Clutch performance
    - Distance profiles
    """

    def __init__(self):
        self.fg_model = FieldGoalProbabilityModel()

    def evaluate_kicker(self, kicks: List[Dict]) -> Dict:
        """
        Evaluate a kicker's performance.

        Parameters:
        -----------
        kicks : list
            Each kick has: distance, made (bool), weather, surface

        Returns:
        --------
        dict with evaluation metrics
        """
        if not kicks:
            return {'error': 'No kicks provided'}

        # Calculate actual and expected
        total_kicks = len(kicks)
        made = sum(1 for k in kicks if k.get('made', False))
        actual_pct = made / total_kicks * 100

        # Calculate expected makes
        expected_makes = sum(
            self.fg_model.predict_probability(
                k['distance'],
                k.get('weather', 'clear'),
                k.get('surface', 'grass')
            )
            for k in kicks
        )
        expected_pct = expected_makes / total_kicks * 100

        # FG+ (100 = average)
        # Made - Expected, converted to index
        fg_plus = 100 + (actual_pct - expected_pct)

        # By distance bucket
        distance_buckets = {
            'under_30': {'range': (0, 30), 'made': 0, 'att': 0},
            '30_39': {'range': (30, 40), 'made': 0, 'att': 0},
            '40_49': {'range': (40, 50), 'made': 0, 'att': 0},
            '50_plus': {'range': (50, 100), 'made': 0, 'att': 0}
        }

        for kick in kicks:
            dist = kick['distance']
            made_kick = kick.get('made', False)

            for bucket, info in distance_buckets.items():
                if info['range'][0] <= dist < info['range'][1]:
                    info['att'] += 1
                    if made_kick:
                        info['made'] += 1
                    break

        # Calculate bucket percentages
        bucket_pcts = {}
        for bucket, info in distance_buckets.items():
            if info['att'] > 0:
                bucket_pcts[bucket] = {
                    'attempts': info['att'],
                    'made': info['made'],
                    'pct': round(info['made'] / info['att'] * 100, 1)
                }

        return {
            'total_kicks': total_kicks,
            'made': made,
            'actual_pct': round(actual_pct, 1),
            'expected_makes': round(expected_makes, 1),
            'expected_pct': round(expected_pct, 1),
            'fg_plus': round(fg_plus, 0),
            'kicks_above_expected': round(made - expected_makes, 1),
            'by_distance': bucket_pcts,
            'evaluation': self._get_evaluation(fg_plus)
        }

    def _get_evaluation(self, fg_plus: float) -> str:
        """Convert FG+ to evaluation."""
        if fg_plus >= 110:
            return 'Elite'
        elif fg_plus >= 105:
            return 'Above Average'
        elif fg_plus >= 95:
            return 'Average'
        elif fg_plus >= 90:
            return 'Below Average'
        else:
            return 'Poor'


# Example
print("\n" + "=" * 70)
print("KICKER EVALUATION")
print("=" * 70)

np.random.seed(42)

# Generate sample kicker data
sample_kicks = []
for _ in range(35):
    distance = np.random.choice(
        [22, 27, 32, 37, 42, 47, 52],
        p=[0.1, 0.15, 0.2, 0.25, 0.2, 0.08, 0.02]
    )

    # Simulate with above-average kicker (1.03 skill)
    prob = fg_model.predict_probability(distance) * 1.03
    made = np.random.random() < prob

    sample_kicks.append({
        'distance': distance,
        'made': made,
        'weather': np.random.choice(['clear', 'dome', 'wind_light'], p=[0.6, 0.3, 0.1])
    })

evaluator = KickerEvaluator()
kicker_result = evaluator.evaluate_kicker(sample_kicks)

print(f"\nKicker Statistics:")
print(f"  Total FG Attempts: {kicker_result['total_kicks']}")
print(f"  Made: {kicker_result['made']}")
print(f"  Actual FG%: {kicker_result['actual_pct']}%")
print(f"  Expected FG%: {kicker_result['expected_pct']}%")
print(f"  FG+: {kicker_result['fg_plus']}")
print(f"  Kicks Above Expected: {kicker_result['kicks_above_expected']:+.1f}")
print(f"  Evaluation: {kicker_result['evaluation']}")

print("\nBy Distance:")
for bucket, stats in kicker_result['by_distance'].items():
    print(f"  {bucket}: {stats['made']}/{stats['attempts']} ({stats['pct']}%)")

10.2 Punting Analytics

Punt Decision Analysis

The decision to punt involves comparing expected points from punting vs. going for it.

class PuntAnalyzer:
    """
    Analyze punting decisions and execution.

    Punt value = EP(receiving team after punt) vs EP(current position)
    """

    def __init__(self):
        # Expected points by field position (offense perspective)
        self.ep_table = self._build_ep_table()

    def _build_ep_table(self) -> Dict[int, float]:
        """Build expected points by yard line."""
        ep = {}
        for yard in range(1, 100):
            if yard <= 10:
                ep[yard] = -0.8 - (10 - yard) * 0.15
            elif yard <= 50:
                ep[yard] = -0.8 + (yard - 10) / 40 * 2.8
            else:
                ep[yard] = 2.0 + ((yard - 50) / 49) * 4.5
        return ep

    def analyze_punt_decision(self, yard_line: int,
                              down: int,
                              distance: int,
                              expected_punt_distance: int = 42) -> Dict:
        """
        Analyze whether to punt or go for it.

        Parameters:
        -----------
        yard_line : int
            Current yard line (0-100, 0 = own goal line)
        down : int
            Current down (typically 4)
        distance : int
            Yards to first down
        expected_punt_distance : int
            Expected net punt yards

        Returns:
        --------
        dict with analysis
        """
        # Current EP
        current_ep = self.ep_table.get(yard_line, 0)

        # EP if punt
        punt_landing = min(99, yard_line + expected_punt_distance)
        opponent_yard_line = 100 - punt_landing
        ep_after_punt = -self.ep_table.get(opponent_yard_line, 0)

        # EP if go for it (simplified)
        # Need conversion probability based on distance
        conv_prob = max(0.2, 0.75 - distance * 0.05)
        ep_if_convert = self.ep_table.get(min(99, yard_line + distance), 0)
        ep_if_fail = -self.ep_table.get(yard_line, 0)

        ep_go_for_it = conv_prob * ep_if_convert + (1 - conv_prob) * ep_if_fail

        # Recommendation
        if ep_go_for_it > ep_after_punt:
            recommendation = 'GO FOR IT'
            advantage = ep_go_for_it - ep_after_punt
        else:
            recommendation = 'PUNT'
            advantage = ep_after_punt - ep_go_for_it

        return {
            'yard_line': yard_line,
            'down': down,
            'distance': distance,
            'current_ep': round(current_ep, 2),
            'ep_if_punt': round(ep_after_punt, 2),
            'ep_if_go': round(ep_go_for_it, 2),
            'conversion_prob': round(conv_prob * 100, 1),
            'recommendation': recommendation,
            'ep_advantage': round(advantage, 2)
        }

    def analyze_punt_execution(self, punts: List[Dict]) -> Dict:
        """
        Analyze punter performance.

        Each punt needs: gross_yards, return_yards, hangtime,
                        inside_20, touchback
        """
        if not punts:
            return {'error': 'No punts'}

        n = len(punts)

        total_gross = sum(p['gross_yards'] for p in punts)
        total_return = sum(p.get('return_yards', 0) for p in punts)
        total_net = total_gross - total_return

        avg_gross = total_gross / n
        avg_net = total_net / n
        avg_hangtime = np.mean([p.get('hangtime', 4.2) for p in punts])

        inside_20 = sum(1 for p in punts if p.get('inside_20', False))
        touchbacks = sum(1 for p in punts if p.get('touchback', False))

        # Calculate punt value (simplified)
        # Good punt = high net yards, good hang time, inside 20s
        punt_value = (avg_net / 45 * 40 +
                      avg_hangtime / 4.5 * 30 +
                      (inside_20 / n) * 30)

        return {
            'total_punts': n,
            'avg_gross': round(avg_gross, 1),
            'avg_net': round(avg_net, 1),
            'avg_hangtime': round(avg_hangtime, 2),
            'inside_20': inside_20,
            'inside_20_pct': round(inside_20 / n * 100, 1),
            'touchbacks': touchbacks,
            'touchback_pct': round(touchbacks / n * 100, 1),
            'punt_value': round(punt_value, 1),
            'evaluation': self._evaluate_punter(punt_value)
        }

    def _evaluate_punter(self, value: float) -> str:
        """Evaluate punter performance."""
        if value >= 85:
            return 'Elite'
        elif value >= 75:
            return 'Good'
        elif value >= 65:
            return 'Average'
        else:
            return 'Below Average'


# Example
print("\n" + "=" * 70)
print("PUNT ANALYSIS")
print("=" * 70)

punt_analyzer = PuntAnalyzer()

# Analyze punt decisions at different field positions
print("\n4th Down Decision Analysis:")
print("-" * 60)
print(f"{'Yard Line':<12} {'Distance':<10} {'EP Punt':<10} {'EP Go':<10} {'Decision':<15}")
print("-" * 60)

for yard_line in [25, 35, 45, 55, 65]:
    for distance in [2, 4, 8]:
        analysis = punt_analyzer.analyze_punt_decision(yard_line, 4, distance)
        print(f"{yard_line:<12} {distance:<10} {analysis['ep_if_punt']:<10.2f} "
              f"{analysis['ep_if_go']:<10.2f} {analysis['recommendation']:<15}")

# Punter evaluation
np.random.seed(42)
sample_punts = []
for _ in range(50):
    gross = np.random.normal(44, 5)
    hangtime = np.random.normal(4.3, 0.3)
    return_yds = max(0, np.random.normal(8, 5))

    inside_20 = gross > 40 and np.random.random() < 0.35
    touchback = gross > 55 and np.random.random() < 0.15

    sample_punts.append({
        'gross_yards': round(gross, 1),
        'return_yards': round(return_yds, 1),
        'hangtime': round(hangtime, 2),
        'inside_20': inside_20 and not touchback,
        'touchback': touchback
    })

punt_result = punt_analyzer.analyze_punt_execution(sample_punts)

print(f"\nPunter Statistics:")
print(f"  Total Punts: {punt_result['total_punts']}")
print(f"  Avg Gross: {punt_result['avg_gross']} yards")
print(f"  Avg Net: {punt_result['avg_net']} yards")
print(f"  Avg Hang Time: {punt_result['avg_hangtime']}s")
print(f"  Inside 20: {punt_result['inside_20']} ({punt_result['inside_20_pct']}%)")
print(f"  Touchbacks: {punt_result['touchbacks']} ({punt_result['touchback_pct']}%)")
print(f"  Evaluation: {punt_result['evaluation']}")

10.3 Kickoff Analysis

Kickoff Strategy

Modern kickoff rules have changed the calculus of kickoff decisions.

class KickoffAnalyzer:
    """
    Analyze kickoff strategies and returns.

    Key decisions:
    - Kick deep vs. pooch/squib
    - Return vs. touchback
    """

    def __init__(self):
        # Average starting positions by result
        self.avg_positions = {
            'touchback': 25,  # Rule-dependent (NFL = 25, college varies)
            'returned': 27,   # Average return to own 27
            'returned_good': 35,
            'returned_poor': 18
        }

    def analyze_kickoff_decision(self,
                                  opponent_return_ability: str = 'average',
                                  game_situation: str = 'normal') -> Dict:
        """
        Analyze optimal kickoff strategy.

        Parameters:
        -----------
        opponent_return_ability : str
            'poor', 'average', 'good', 'elite'
        game_situation : str
            'normal', 'need_onside', 'protect_lead'
        """
        # Expected starting position by strategy
        if game_situation == 'need_onside':
            return {
                'recommendation': 'ONSIDE KICK',
                'reason': 'Need possession',
                'expected_recovery_rate': 15
            }

        # Deep kick analysis
        touchback_rate_base = 0.55  # Base touchback rate

        # Adjust for opponent return ability
        ability_adj = {
            'poor': 0.10,      # More likely to touchback
            'average': 0.00,
            'good': -0.08,
            'elite': -0.15
        }

        touchback_rate = touchback_rate_base + ability_adj.get(opponent_return_ability, 0)

        # Expected position
        avg_return_position = self.avg_positions['returned']
        if opponent_return_ability == 'elite':
            avg_return_position = 32
        elif opponent_return_ability == 'good':
            avg_return_position = 29
        elif opponent_return_ability == 'poor':
            avg_return_position = 23

        ep_deep_kick = (touchback_rate * self.avg_positions['touchback'] +
                        (1 - touchback_rate) * avg_return_position)

        # Directional/pooch kick
        ep_directional = 30  # Typically pin around 30

        return {
            'opponent_return_ability': opponent_return_ability,
            'deep_kick': {
                'touchback_rate': round(touchback_rate * 100, 1),
                'expected_opponent_start': round(ep_deep_kick, 1)
            },
            'directional_kick': {
                'expected_opponent_start': ep_directional
            },
            'recommendation': 'DEEP KICK' if ep_deep_kick < ep_directional else 'DIRECTIONAL'
        }

    def analyze_returns(self, returns: List[Dict]) -> Dict:
        """
        Analyze kick return performance.

        Each return needs: starting_yard (touchback location),
                          return_yards, touchdown
        """
        if not returns:
            return {'error': 'No returns'}

        touchbacks = [r for r in returns if r.get('touchback', False)]
        returned = [r for r in returns if not r.get('touchback', False)]

        touchback_rate = len(touchbacks) / len(returns) * 100

        if returned:
            avg_return = np.mean([r['return_yards'] for r in returned])
            avg_start = np.mean([r.get('starting_yard', 25) + r['return_yards']
                                for r in returned])
            return_tds = sum(1 for r in returned if r.get('touchdown', False))
        else:
            avg_return = 0
            avg_start = 25
            return_tds = 0

        # Compare to touchback value
        touchback_value = 25
        return_value_vs_tb = avg_start - touchback_value if returned else 0

        return {
            'total_kickoffs': len(returns),
            'touchbacks': len(touchbacks),
            'touchback_rate': round(touchback_rate, 1),
            'returns': len(returned),
            'avg_return_yards': round(avg_return, 1) if returned else 0,
            'avg_starting_position': round(avg_start, 1) if returned else touchback_value,
            'return_tds': return_tds,
            'yards_vs_touchback': round(return_value_vs_tb, 1),
            'evaluation': 'RETURN' if return_value_vs_tb > 2 else 'TOUCHBACK'
        }


# Example
print("\n" + "=" * 70)
print("KICKOFF ANALYSIS")
print("=" * 70)

ko_analyzer = KickoffAnalyzer()

print("\nKickoff Strategy by Opponent:")
print("-" * 60)
for ability in ['poor', 'average', 'good', 'elite']:
    analysis = ko_analyzer.analyze_kickoff_decision(ability)
    print(f"{ability.capitalize():<10}: TB Rate {analysis['deep_kick']['touchback_rate']}%, "
          f"Exp Start: {analysis['deep_kick']['expected_opponent_start']}, "
          f"Rec: {analysis['recommendation']}")

# Return analysis
np.random.seed(42)
sample_returns = []
for _ in range(40):
    touchback = np.random.random() < 0.55

    if touchback:
        sample_returns.append({'touchback': True, 'return_yards': 0})
    else:
        return_yds = max(0, np.random.normal(24, 10))
        td = return_yds > 75
        sample_returns.append({
            'touchback': False,
            'starting_yard': 25,
            'return_yards': round(return_yds, 1),
            'touchdown': td
        })

return_result = ko_analyzer.analyze_returns(sample_returns)

print(f"\nReturn Unit Analysis:")
print(f"  Total Kickoffs: {return_result['total_kickoffs']}")
print(f"  Touchback Rate: {return_result['touchback_rate']}%")
print(f"  Avg Return Yards: {return_result['avg_return_yards']}")
print(f"  Avg Starting Position: {return_result['avg_starting_position']}")
print(f"  Yards vs Touchback: {return_result['yards_vs_touchback']:+.1f}")
print(f"  Recommendation: {return_result['evaluation']}")

10.4 Coverage Unit Analysis

Punt and Kick Coverage

Coverage units directly impact field position.

class CoverageAnalyzer:
    """
    Analyze punt and kick coverage performance.
    """

    def analyze_punt_coverage(self, punts: List[Dict]) -> Dict:
        """
        Analyze punt coverage unit.

        Each punt needs: gross_yards, return_yards,
                        inside_20, touchback, tackle_yard
        """
        if not punts:
            return {'error': 'No punts'}

        n = len(punts)

        returned_punts = [p for p in punts if not p.get('touchback', False)
                         and not p.get('fair_catch', False)]

        if returned_punts:
            avg_return_allowed = np.mean([p.get('return_yards', 0) for p in returned_punts])
        else:
            avg_return_allowed = 0

        # Calculate net yards
        total_gross = sum(p['gross_yards'] for p in punts)
        total_return = sum(p.get('return_yards', 0) for p in punts)
        total_net = total_gross - total_return
        avg_net = total_net / n

        # Coverage grade
        # Lower return yards = better coverage
        if avg_return_allowed <= 4:
            coverage_grade = 'A'
        elif avg_return_allowed <= 7:
            coverage_grade = 'B'
        elif avg_return_allowed <= 10:
            coverage_grade = 'C'
        elif avg_return_allowed <= 13:
            coverage_grade = 'D'
        else:
            coverage_grade = 'F'

        # Forced fair catches (good)
        fair_catches = sum(1 for p in punts if p.get('fair_catch', False))

        return {
            'total_punts': n,
            'returns_allowed': len(returned_punts),
            'avg_return_allowed': round(avg_return_allowed, 1),
            'avg_net_punt': round(avg_net, 1),
            'fair_catches_forced': fair_catches,
            'coverage_grade': coverage_grade
        }

    def analyze_kick_coverage(self, kickoffs: List[Dict]) -> Dict:
        """
        Analyze kickoff coverage unit.
        """
        if not kickoffs:
            return {'error': 'No kickoffs'}

        n = len(kickoffs)

        touchbacks = [k for k in kickoffs if k.get('touchback', False)]
        returned = [k for k in kickoffs if not k.get('touchback', False)]

        if returned:
            avg_return_allowed = np.mean([k['return_yards'] for k in returned])
            avg_opponent_start = np.mean([k.get('tackle_yard', 25) for k in returned])
            return_tds_allowed = sum(1 for k in returned if k.get('touchdown', False))
        else:
            avg_return_allowed = 0
            avg_opponent_start = 25
            return_tds_allowed = 0

        # Coverage grade
        if avg_opponent_start <= 22:
            coverage_grade = 'A'
        elif avg_opponent_start <= 25:
            coverage_grade = 'B'
        elif avg_opponent_start <= 28:
            coverage_grade = 'C'
        else:
            coverage_grade = 'D'

        return {
            'total_kickoffs': n,
            'touchbacks': len(touchbacks),
            'returns_allowed': len(returned),
            'avg_return_allowed': round(avg_return_allowed, 1),
            'avg_opponent_start': round(avg_opponent_start, 1),
            'return_tds_allowed': return_tds_allowed,
            'coverage_grade': coverage_grade
        }


# Example
print("\n" + "=" * 70)
print("COVERAGE UNIT ANALYSIS")
print("=" * 70)

coverage_analyzer = CoverageAnalyzer()

# Generate punt coverage data
np.random.seed(42)
punt_coverage_data = []
for _ in range(45):
    gross = np.random.normal(43, 5)
    touchback = gross > 58 and np.random.random() < 0.12
    fair_catch = not touchback and np.random.random() < 0.35

    if touchback or fair_catch:
        return_yds = 0
    else:
        return_yds = max(0, np.random.normal(7, 4))

    punt_coverage_data.append({
        'gross_yards': round(gross, 1),
        'return_yards': round(return_yds, 1),
        'touchback': touchback,
        'fair_catch': fair_catch
    })

punt_cov_result = coverage_analyzer.analyze_punt_coverage(punt_coverage_data)

print(f"\nPunt Coverage:")
print(f"  Returns Allowed: {punt_cov_result['returns_allowed']}")
print(f"  Avg Return Allowed: {punt_cov_result['avg_return_allowed']} yards")
print(f"  Fair Catches Forced: {punt_cov_result['fair_catches_forced']}")
print(f"  Coverage Grade: {punt_cov_result['coverage_grade']}")

10.5 Fourth Down Decision Making

Go-For-It Analysis

Fourth down decisions are among the most impactful in football.

class FourthDownDecisionModel:
    """
    Analyze fourth down decisions using expected points.

    Compares:
    - Expected points from going for it
    - Expected points from field goal attempt
    - Expected points from punting
    """

    def __init__(self):
        self.fg_model = FieldGoalProbabilityModel()
        self.ep_table = self._build_ep_table()

        # Fourth down conversion probabilities by distance
        self.conversion_probs = {
            1: 0.75, 2: 0.65, 3: 0.55, 4: 0.48, 5: 0.42,
            6: 0.38, 7: 0.35, 8: 0.32, 9: 0.29, 10: 0.26
        }

    def _build_ep_table(self) -> Dict[int, float]:
        """Build expected points table."""
        ep = {}
        for yard in range(1, 100):
            if yard <= 10:
                ep[yard] = -1.0 - (10 - yard) * 0.12
            elif yard <= 50:
                ep[yard] = -1.0 + (yard - 10) / 40 * 3.0
            else:
                ep[yard] = 2.0 + ((yard - 50) / 49) * 4.5
        return ep

    def analyze_decision(self, yard_line: int, distance: int,
                         time_remaining: int = 900,
                         score_diff: int = 0) -> Dict:
        """
        Analyze fourth down decision.

        Parameters:
        -----------
        yard_line : int
            Yards from own goal line (own 20 = 20, opp 20 = 80)
        distance : int
            Yards to first down
        time_remaining : int
            Seconds remaining in game
        score_diff : int
            Current score difference (positive = winning)
        """
        # Current expected points
        current_ep = self.ep_table.get(yard_line, 0)

        # Calculate EP for each option
        ep_go = self._calculate_go_for_it_ep(yard_line, distance)
        ep_fg = self._calculate_fg_ep(yard_line)
        ep_punt = self._calculate_punt_ep(yard_line)

        # Find best option
        options = {
            'GO_FOR_IT': ep_go,
            'FIELD_GOAL': ep_fg if yard_line >= 60 else -999,
            'PUNT': ep_punt if yard_line < 65 else -999
        }

        best_option = max(options, key=options.get)

        # Calculate break-even conversion rate for going for it
        if ep_punt > -999:
            # EP if convert
            ep_convert = self.ep_table.get(min(99, yard_line + distance), 0)
            # EP if fail
            ep_fail = -self.ep_table.get(yard_line, 0)
            # Break-even: P * EP_conv + (1-P) * EP_fail = EP_punt
            # P = (EP_punt - EP_fail) / (EP_conv - EP_fail)
            if ep_convert != ep_fail:
                break_even = (ep_punt - ep_fail) / (ep_convert - ep_fail)
            else:
                break_even = 0.5
        else:
            break_even = 0.5

        return {
            'yard_line': yard_line,
            'distance': distance,
            'conversion_prob': self._get_conversion_prob(distance) * 100,
            'ep_go_for_it': round(ep_go, 2),
            'ep_field_goal': round(ep_fg, 2) if ep_fg > -999 else 'N/A',
            'ep_punt': round(ep_punt, 2) if ep_punt > -999 else 'N/A',
            'recommendation': best_option,
            'break_even_conv_rate': round(break_even * 100, 1),
            'ep_gained_by_going': round(ep_go - max(ep_fg, ep_punt), 2)
        }

    def _get_conversion_prob(self, distance: int) -> float:
        """Get conversion probability by distance."""
        if distance in self.conversion_probs:
            return self.conversion_probs[distance]
        elif distance > 10:
            return max(0.15, 0.26 - (distance - 10) * 0.02)
        else:
            return 0.80

    def _calculate_go_for_it_ep(self, yard_line: int, distance: int) -> float:
        """Calculate EP from going for it."""
        conv_prob = self._get_conversion_prob(distance)

        # EP if convert
        new_yard_line = min(99, yard_line + distance)
        ep_convert = self.ep_table.get(new_yard_line, 0)

        # Check for touchdown
        if new_yard_line >= 100:
            ep_convert = 7.0

        # EP if fail (opponent gets ball)
        ep_fail = -self.ep_table.get(yard_line, 0)

        return conv_prob * ep_convert + (1 - conv_prob) * ep_fail

    def _calculate_fg_ep(self, yard_line: int) -> float:
        """Calculate EP from field goal attempt."""
        if yard_line < 55:  # Too far
            return -999

        fg_distance = 100 - yard_line + 17  # Snap + hold
        prob = self.fg_model.predict_probability(fg_distance)

        # EP if make = 3, if miss = opponent at LOS
        ep_make = 3.0
        opponent_yard = 100 - yard_line
        ep_miss = -self.ep_table.get(opponent_yard, 0)

        return prob * ep_make + (1 - prob) * ep_miss

    def _calculate_punt_ep(self, yard_line: int) -> float:
        """Calculate EP from punting."""
        if yard_line > 70:  # Too close to punt
            return -999

        # Expected punt distance
        expected_net = 42

        landing_spot = min(99, yard_line + expected_net)
        opponent_yard = 100 - landing_spot

        return -self.ep_table.get(opponent_yard, 0)


# Example
print("\n" + "=" * 70)
print("FOURTH DOWN DECISION MODEL")
print("=" * 70)

fourth_down_model = FourthDownDecisionModel()

print("\n4th Down Decision Chart:")
print("-" * 80)
print(f"{'Field Pos':<12} {'Distance':<10} {'Conv%':<8} {'EP Go':<10} {'EP FG':<10} {'EP Punt':<10} {'Decision':<12}")
print("-" * 80)

for yard_line in [35, 45, 55, 65, 75, 85, 95]:
    for distance in [1, 3, 5, 10]:
        analysis = fourth_down_model.analyze_decision(yard_line, distance)
        ep_fg = analysis['ep_field_goal'] if analysis['ep_field_goal'] != 'N/A' else '-'
        ep_punt = analysis['ep_punt'] if analysis['ep_punt'] != 'N/A' else '-'

        print(f"Own {yard_line:<7} {distance:<10} {analysis['conversion_prob']:<8.0f} "
              f"{analysis['ep_go_for_it']:<10.2f} {ep_fg:<10} {ep_punt:<10} "
              f"{analysis['recommendation']:<12}")

10.6 Comprehensive Special Teams Evaluation

Building a Complete Special Teams Rating

class SpecialTeamsRatingSystem:
    """
    Comprehensive special teams evaluation.

    Components:
    - Kicking (FG + XP)
    - Punting (distance + hangtime + placement)
    - Kick returns
    - Punt returns
    - Coverage units
    """

    def __init__(self):
        self.weights = {
            'kicking': 0.25,
            'punting': 0.20,
            'kick_returns': 0.15,
            'punt_returns': 0.15,
            'kick_coverage': 0.12,
            'punt_coverage': 0.13
        }

    def calculate_team_rating(self, data: Dict) -> Dict:
        """
        Calculate comprehensive special teams rating.

        Parameters:
        -----------
        data : dict
            Contains all special teams statistics
        """
        # Calculate component scores (0-100)
        kicking_score = self._score_kicking(data.get('kicking', {}))
        punting_score = self._score_punting(data.get('punting', {}))
        kr_score = self._score_kick_returns(data.get('kick_returns', {}))
        pr_score = self._score_punt_returns(data.get('punt_returns', {}))
        kick_cov_score = self._score_kick_coverage(data.get('kick_coverage', {}))
        punt_cov_score = self._score_punt_coverage(data.get('punt_coverage', {}))

        # Weighted composite
        composite = (
            kicking_score * self.weights['kicking'] +
            punting_score * self.weights['punting'] +
            kr_score * self.weights['kick_returns'] +
            pr_score * self.weights['punt_returns'] +
            kick_cov_score * self.weights['kick_coverage'] +
            punt_cov_score * self.weights['punt_coverage']
        )

        return {
            'kicking': {'score': round(kicking_score, 1), 'weight': self.weights['kicking']},
            'punting': {'score': round(punting_score, 1), 'weight': self.weights['punting']},
            'kick_returns': {'score': round(kr_score, 1), 'weight': self.weights['kick_returns']},
            'punt_returns': {'score': round(pr_score, 1), 'weight': self.weights['punt_returns']},
            'kick_coverage': {'score': round(kick_cov_score, 1), 'weight': self.weights['kick_coverage']},
            'punt_coverage': {'score': round(punt_cov_score, 1), 'weight': self.weights['punt_coverage']},
            'composite_score': round(composite, 1),
            'rating': self._get_rating(composite)
        }

    def _score_kicking(self, data: Dict) -> float:
        """Score kicking unit."""
        if not data:
            return 50

        fg_pct = data.get('fg_pct', 80)
        fg_plus = data.get('fg_plus', 100)
        xp_pct = data.get('xp_pct', 95)

        return (fg_pct / 90 * 50 + fg_plus / 110 * 35 + xp_pct / 100 * 15)

    def _score_punting(self, data: Dict) -> float:
        """Score punting unit."""
        if not data:
            return 50

        avg_net = data.get('avg_net', 40)
        inside_20_pct = data.get('inside_20_pct', 35)

        return min(100, avg_net / 44 * 60 + inside_20_pct / 40 * 40)

    def _score_kick_returns(self, data: Dict) -> float:
        """Score kick returns."""
        if not data:
            return 50

        avg_return = data.get('avg_return', 22)
        tds = data.get('tds', 0)

        return min(100, avg_return / 25 * 80 + tds * 10)

    def _score_punt_returns(self, data: Dict) -> float:
        """Score punt returns."""
        if not data:
            return 50

        avg_return = data.get('avg_return', 8)
        tds = data.get('tds', 0)

        return min(100, avg_return / 12 * 80 + tds * 10)

    def _score_kick_coverage(self, data: Dict) -> float:
        """Score kick coverage."""
        if not data:
            return 50

        avg_opponent_start = data.get('avg_opponent_start', 25)

        # Lower is better
        return max(0, 100 - (avg_opponent_start - 20) * 4)

    def _score_punt_coverage(self, data: Dict) -> float:
        """Score punt coverage."""
        if not data:
            return 50

        avg_return_allowed = data.get('avg_return_allowed', 8)

        # Lower is better
        return max(0, 100 - avg_return_allowed * 5)

    def _get_rating(self, score: float) -> str:
        """Convert score to rating."""
        if score >= 85:
            return 'Elite'
        elif score >= 75:
            return 'Good'
        elif score >= 60:
            return 'Average'
        elif score >= 45:
            return 'Below Average'
        else:
            return 'Poor'


# Example
print("\n" + "=" * 70)
print("SPECIAL TEAMS RATING SYSTEM")
print("=" * 70)

st_system = SpecialTeamsRatingSystem()

sample_st_data = {
    'kicking': {
        'fg_pct': 85.7,
        'fg_plus': 105,
        'xp_pct': 97.5
    },
    'punting': {
        'avg_net': 41.8,
        'inside_20_pct': 38.2
    },
    'kick_returns': {
        'avg_return': 23.5,
        'tds': 1
    },
    'punt_returns': {
        'avg_return': 9.2,
        'tds': 0
    },
    'kick_coverage': {
        'avg_opponent_start': 24.2
    },
    'punt_coverage': {
        'avg_return_allowed': 6.8
    }
}

team_rating = st_system.calculate_team_rating(sample_st_data)

print("\nTeam Special Teams Rating:")
print("-" * 50)
for component, info in team_rating.items():
    if component not in ['composite_score', 'rating']:
        print(f"  {component}: {info['score']} (weight: {info['weight']*100:.0f}%)")

print(f"\n  COMPOSITE SCORE: {team_rating['composite_score']}")
print(f"  OVERALL RATING: {team_rating['rating']}")

Summary

This chapter covered comprehensive special teams analytics:

  1. Field Goal Models: Probability based on distance and conditions
  2. Punting Analysis: Decision-making and execution evaluation
  3. Kickoff Strategy: When to kick deep vs. directional
  4. Coverage Units: Measuring coverage effectiveness
  5. Fourth Down Decisions: Data-driven go-for-it analysis
  6. Comprehensive Rating: Combining all special teams elements

Key Takeaways

  • Field goal probability drops significantly beyond 45 yards
  • Punt decisions should consider expected points, not just field position
  • Kickoff returns are often not worth the risk vs. touchbacks
  • Coverage units directly impact field position battle
  • Fourth down decisions should be based on EP, not convention
  • Special teams matter: 15-20% of plays with outsized impact

Looking Ahead

Chapter 11 explores Efficiency Metrics (EPA, Success Rate) in depth, unifying the concepts we've introduced throughout Part 2 into a comprehensive framework for football analytics.


References

  1. Burke, B. (2014). "Expected Points and Win Probability"
  2. Morris, B. (2014). "When to Go For It on 4th Down"
  3. Lopez, M. (2016). "NFL Kicker Analysis"
  4. Football Outsiders. "Special Teams DVOA Methodology"