Traditional football statistics often mislead us. A quarterback who throws for 350 yards might have had a worse game than one who threw for 200 yards. A running back with 100 yards on 30 carries contributed less than one with 80 yards on 15 carries...
In This Chapter
Chapter 11: Efficiency Metrics (EPA, Success Rate)
Introduction
Traditional football statistics often mislead us. A quarterback who throws for 350 yards might have had a worse game than one who threw for 200 yards. A running back with 100 yards on 30 carries contributed less than one with 80 yards on 15 carries. The problem isn't the statistics themselves—it's that they lack context.
Efficiency metrics solve this problem by measuring performance relative to expectation. Rather than counting yards or touchdowns, efficiency metrics ask: "Did this play move the team closer to scoring?" and "By how much?"
This chapter explores the two most important efficiency frameworks in modern football analytics: Expected Points Added (EPA) and Success Rate. These metrics have revolutionized how we evaluate players, analyze strategies, and predict outcomes. By the end of this chapter, you'll understand not just how to calculate these metrics, but why they provide such powerful insights into football performance.
Learning Objectives
After completing this chapter, you will be able to:
- Explain the conceptual foundation of expected points
- Build expected points models from historical data
- Calculate EPA for individual plays, drives, and games
- Understand and apply success rate metrics
- Combine EPA and success rate for comprehensive analysis
- Apply efficiency metrics to player and team evaluation
- Recognize the limitations and appropriate uses of efficiency metrics
11.1 The Expected Points Framework
11.1.1 From Field Position to Point Expectation
The foundation of modern football analytics rests on a simple question: "Given the current game state, how many points can we expect the team with the ball to score on this drive?"
This question transforms field position from a vague concept into a precise numerical value. Being at your own 20-yard line isn't just "bad field position"—it corresponds to a specific expected point value that we can calculate from historical data.
The Basic Intuition:
Consider two scenarios: 1. First and 10 at your own 5-yard line 2. First and goal at the opponent's 1-yard line
In scenario 1, the expected outcome is likely negative—you'll probably punt, and the opponent will get decent field position. In scenario 2, you're almost certainly going to score. These intuitions can be quantified.
Building the EP Table:
To build an expected points table, we analyze thousands of historical plays and track what happened from each field position. For every first-and-10 from each yard line, we calculate:
- Probability of scoring a touchdown on this drive
- Probability of kicking a field goal
- Probability of turning the ball over
- Probability of punting
- Expected points for opponent's subsequent drive
"""
Building an Expected Points Table
"""
import numpy as np
from typing import Dict, List
def calculate_ep_from_outcomes(yard_line: int, outcomes: List[Dict]) -> float:
"""
Calculate expected points at a yard line from historical outcomes.
Args:
yard_line: Yard line (1-99 from own end zone)
outcomes: List of drive outcomes starting from this position
Returns:
Expected points value
"""
total_ep = 0
for outcome in outcomes:
if outcome['result'] == 'touchdown':
total_ep += 7.0 # Including extra point expectation
elif outcome['result'] == 'field_goal':
total_ep += 3.0
elif outcome['result'] == 'safety':
total_ep += 2.0
elif outcome['result'] == 'turnover':
# Subtract expected points of opponent's resulting drive
opp_yard_line = 100 - outcome['turnover_position']
total_ep -= estimate_ep(opp_yard_line)
elif outcome['result'] == 'punt':
opp_yard_line = outcome['punt_result_position']
total_ep -= estimate_ep(opp_yard_line)
return total_ep / len(outcomes)
def estimate_ep(yard_line: int) -> float:
"""
Estimate expected points using a simplified model.
This is a placeholder for the full EP table.
In practice, this comes from historical data.
"""
# Simplified linear model
if yard_line <= 50:
return -1.5 + (yard_line / 50) * 3.5
else:
return 2.0 + ((yard_line - 50) / 49) * 4.0
11.1.2 The Complete Game State
Field position alone doesn't tell the whole story. The expected points at the opponent's 30-yard line on 1st-and-10 is very different from 4th-and-20 at the same spot.
A complete expected points model accounts for:
- Field position (yard line)
- Down (1st, 2nd, 3rd, or 4th)
- Distance (yards to go for first down)
- Time remaining (in some advanced models)
- Score differential (in win probability models)
The EP Table Structure:
class ExpectedPointsModel:
"""
Complete expected points model based on game state.
"""
def __init__(self):
# EP adjustments by down (relative to 1st down)
self.down_adjustments = {
1: 0.0,
2: -0.4,
3: -0.9,
4: -1.5
}
# Build base EP table
self.ep_table = self._build_ep_table()
def _build_ep_table(self) -> Dict[int, float]:
"""Build baseline EP by yard line (1st and 10)."""
ep = {}
for yard in range(1, 100):
if yard <= 10:
# Deep in own territory - negative EP
ep[yard] = -1.8 + (yard / 10) * 0.8
elif yard <= 25:
# Own territory - slightly negative
ep[yard] = -1.0 + ((yard - 10) / 15) * 0.8
elif yard <= 50:
# Approaching midfield - around zero
ep[yard] = -0.2 + ((yard - 25) / 25) * 1.7
elif yard <= 75:
# Opponent territory - positive
ep[yard] = 1.5 + ((yard - 50) / 25) * 1.5
else:
# Red zone - high EP
ep[yard] = 3.0 + ((yard - 75) / 24) * 3.0
return ep
def get_ep(self, yard_line: int, down: int, distance: int) -> float:
"""
Get expected points for a specific game state.
Args:
yard_line: Yard line (1-99, where 99 is opponent's 1)
down: Current down (1-4)
distance: Yards to go for first down
Returns:
Expected points
"""
# Get baseline EP for field position
base_ep = self.ep_table.get(min(99, max(1, yard_line)), 0)
# Apply down adjustment
down_adj = self.down_adjustments.get(down, 0)
# Apply distance adjustment (penalize long distance situations)
if down > 1:
if distance > 10:
distance_adj = -0.5
elif distance > 7:
distance_adj = -0.2
else:
distance_adj = 0
else:
distance_adj = 0
return base_ep + down_adj + distance_adj
11.1.3 Historical EP Values
Based on analysis of college football play-by-play data, here are typical expected points values:
| Field Position | 1st & 10 | 2nd & 7 | 3rd & 5 | 4th & 1 |
|---|---|---|---|---|
| Own 5 | -1.6 | -2.1 | -2.6 | -3.0 |
| Own 20 | -0.8 | -1.3 | -1.8 | -2.2 |
| Own 35 | 0.2 | -0.3 | -0.8 | -1.0 |
| Midfield | 1.5 | 1.0 | 0.5 | 0.3 |
| Opp 35 | 2.5 | 2.0 | 1.5 | 1.5 |
| Opp 20 | 3.8 | 3.3 | 2.8 | 2.8 |
| Opp 5 | 5.2 | 4.7 | 4.2 | 4.5 |
| Opp 1 | 5.8 | 5.3 | 4.8 | 5.5 |
Key Observations:
- EP increases as you move down the field (obvious but now quantified)
- Each down costs roughly 0.4-0.5 EP
- 4th down at goal line has higher EP than 3rd down (because you can score)
- The EP curve isn't linear—red zone yards are worth more
11.2 Expected Points Added (EPA)
11.2.1 The Core Calculation
Expected Points Added measures the change in expected points from one play to the next. It answers the question: "How much did this play change the team's expected scoring?"
The Formula:
EPA = EP_after - EP_before
That's it. The simplicity of this formula belies its power.
Example Calculations:
Example 1: Successful Pass - Before: 1st & 10 at own 25, EP = 0.5 - After: 1st & 10 at own 40, EP = 1.3 - EPA = 1.3 - 0.5 = +0.8
Example 2: Sack - Before: 2nd & 7 at opp 35, EP = 2.0 - After: 3rd & 15 at opp 43, EP = 0.8 - EPA = 0.8 - 2.0 = -1.2
Example 3: Turnover - Before: 1st & 10 at opp 30, EP = 2.8 - After: Opponent 1st & 10 at their 30, EP for them = 2.8 - EPA = -2.8 - 2.8 = -5.6 (catastrophic)
11.2.2 EPA Implementation
class EPACalculator:
"""
Calculate Expected Points Added for plays.
"""
def __init__(self):
self.ep_model = ExpectedPointsModel()
def calculate_play_epa(self, play: Dict) -> float:
"""
Calculate EPA for a single play.
Args:
play: Dictionary containing play information
- yard_line_before: Starting yard line
- yard_line_after: Ending yard line
- down_before: Starting down
- down_after: Ending down
- distance_before: Starting distance
- distance_after: Ending distance
- turnover: Boolean
- touchdown: Boolean
- field_goal: Boolean
Returns:
EPA for the play
"""
# Get EP before play
ep_before = self.ep_model.get_ep(
play['yard_line_before'],
play['down_before'],
play['distance_before']
)
# Handle special outcomes
if play.get('touchdown'):
ep_after = 7.0 # Touchdown + expected XP
elif play.get('field_goal_made'):
ep_after = 3.0
elif play.get('field_goal_missed'):
# Opponent gets ball at spot of kick
opp_yard_line = 100 - play['yard_line_after']
ep_after = -self.ep_model.get_ep(opp_yard_line, 1, 10)
elif play.get('turnover'):
# Opponent gets ball
opp_yard_line = 100 - play['yard_line_after']
ep_after = -self.ep_model.get_ep(opp_yard_line, 1, 10)
elif play.get('safety'):
ep_after = -2.0 # Opponent scores and gets ball
else:
# Normal play
ep_after = self.ep_model.get_ep(
play['yard_line_after'],
play['down_after'],
play['distance_after']
)
return round(ep_after - ep_before, 2)
def calculate_drive_epa(self, plays: List[Dict]) -> Dict:
"""
Calculate EPA for an entire drive.
"""
play_epas = [self.calculate_play_epa(p) for p in plays]
return {
'total_epa': round(sum(play_epas), 2),
'plays': len(plays),
'epa_per_play': round(sum(play_epas) / len(plays), 3) if plays else 0,
'positive_plays': sum(1 for e in play_epas if e > 0),
'negative_plays': sum(1 for e in play_epas if e < 0)
}
11.2.3 EPA by Play Type
Understanding how EPA distributes across play types reveals strategic insights.
Typical EPA Values:
| Play Type | Mean EPA | Median EPA | Std Dev |
|---|---|---|---|
| Pass (complete) | +0.35 | +0.25 | 0.85 |
| Pass (incomplete) | -0.35 | -0.30 | 0.45 |
| Pass (interception) | -4.20 | -4.00 | 1.50 |
| Rush (all) | +0.05 | -0.10 | 0.65 |
| Rush (success) | +0.55 | +0.40 | 0.50 |
| Rush (failure) | -0.45 | -0.35 | 0.40 |
| Sack | -1.40 | -1.20 | 0.80 |
| Penalty (offensive) | -0.60 | -0.50 | 0.70 |
| Penalty (defensive) | +0.70 | +0.60 | 0.65 |
Key Insights:
- Passing has higher variance than rushing - Both the upside and downside are greater
- Turnovers are devastating - An interception costs 4+ expected points
- The average rush is barely positive - Despite this, rushing has strategic value
- Sacks are very costly - Worse than an incomplete pass
11.2.4 EPA Aggregation and Analysis
EPA's power comes from aggregation. We can sum EPA across:
- All plays for a team (team EPA)
- All plays for a player (player EPA)
- All passing plays (passing EPA)
- All plays by quarter, by down, by situation
class EPAAnalyzer:
"""
Analyze EPA across various dimensions.
"""
def __init__(self, epa_calculator: EPACalculator):
self.calculator = epa_calculator
def analyze_team_season(self, plays: List[Dict]) -> Dict:
"""
Comprehensive team EPA analysis for a season.
"""
# Calculate EPA for all plays
for play in plays:
play['epa'] = self.calculator.calculate_play_epa(play)
# Overall metrics
total_epa = sum(p['epa'] for p in plays)
total_plays = len(plays)
# By play type
pass_plays = [p for p in plays if p.get('play_type') == 'pass']
rush_plays = [p for p in plays if p.get('play_type') == 'rush']
pass_epa = sum(p['epa'] for p in pass_plays)
rush_epa = sum(p['epa'] for p in rush_plays)
# By down
by_down = {}
for down in [1, 2, 3, 4]:
down_plays = [p for p in plays if p['down_before'] == down]
if down_plays:
by_down[down] = {
'plays': len(down_plays),
'total_epa': sum(p['epa'] for p in down_plays),
'epa_per_play': sum(p['epa'] for p in down_plays) / len(down_plays)
}
# By field position zone
zones = {
'own_0_25': [p for p in plays if p['yard_line_before'] <= 25],
'own_25_50': [p for p in plays if 25 < p['yard_line_before'] <= 50],
'opp_50_25': [p for p in plays if 50 < p['yard_line_before'] <= 75],
'red_zone': [p for p in plays if p['yard_line_before'] > 75]
}
by_zone = {}
for zone_name, zone_plays in zones.items():
if zone_plays:
by_zone[zone_name] = {
'plays': len(zone_plays),
'total_epa': sum(p['epa'] for p in zone_plays),
'epa_per_play': sum(p['epa'] for p in zone_plays) / len(zone_plays)
}
return {
'total_epa': round(total_epa, 1),
'total_plays': total_plays,
'epa_per_play': round(total_epa / total_plays, 3),
'passing': {
'plays': len(pass_plays),
'total_epa': round(pass_epa, 1),
'epa_per_play': round(pass_epa / len(pass_plays), 3) if pass_plays else 0
},
'rushing': {
'plays': len(rush_plays),
'total_epa': round(rush_epa, 1),
'epa_per_play': round(rush_epa / len(rush_plays), 3) if rush_plays else 0
},
'by_down': by_down,
'by_zone': by_zone
}
11.3 Success Rate
11.3.1 Defining Success
While EPA measures the magnitude of value added, Success Rate measures consistency. A "successful" play is one that keeps the offense on schedule—maintaining a reasonable chance of converting.
The Standard Definition:
| Down | Yards Needed for Success |
|---|---|
| 1st | 40% of distance (4 yards on 1st & 10) |
| 2nd | 50% of distance |
| 3rd/4th | 100% of distance (first down) |
class SuccessRateCalculator:
"""
Calculate success rate metrics.
"""
def __init__(self):
# Success thresholds by down
self.thresholds = {
1: 0.40, # 40% of distance
2: 0.50, # 50% of distance
3: 1.00, # Full distance
4: 1.00 # Full distance
}
def is_successful(self, play: Dict) -> bool:
"""
Determine if a play was successful.
Args:
play: Dictionary with yards_gained, down, distance
Returns:
Boolean indicating success
"""
down = play['down']
distance = play['distance']
yards_gained = play['yards_gained']
# Get threshold for this down
threshold = self.thresholds.get(down, 1.0)
# Calculate required yards
required_yards = distance * threshold
# Check for touchdown (always successful)
if play.get('touchdown'):
return True
# Check for first down (always successful)
if yards_gained >= distance:
return True
# Check against threshold
return yards_gained >= required_yards
def calculate_success_rate(self, plays: List[Dict]) -> Dict:
"""
Calculate success rate for a collection of plays.
"""
if not plays:
return {'success_rate': 0, 'successes': 0, 'plays': 0}
successes = sum(1 for p in plays if self.is_successful(p))
return {
'success_rate': round(successes / len(plays), 3),
'successes': successes,
'plays': len(plays),
'success_pct': f"{successes / len(plays) * 100:.1f}%"
}
11.3.2 Success Rate Benchmarks
College Football Averages:
| Situation | Average Success Rate |
|---|---|
| Overall | 43-45% |
| 1st Down | 45-48% |
| 2nd Down | 40-43% |
| 3rd Down | 38-42% |
| Early Downs (1st-2nd) | 43-46% |
| Passing (all) | 44-47% |
| Rushing (all) | 42-44% |
| Red Zone | 48-52% |
Team Success Rate Distribution:
- Elite offenses: 48%+ success rate
- Good offenses: 45-48%
- Average offenses: 42-45%
- Below average: 38-42%
- Poor offenses: <38%
11.3.3 Success Rate by Situation
Success rate analysis becomes powerful when segmented by situation.
class SuccessRateAnalyzer:
"""
Comprehensive success rate analysis.
"""
def __init__(self):
self.calculator = SuccessRateCalculator()
def analyze_team(self, plays: List[Dict]) -> Dict:
"""
Complete success rate analysis for a team.
"""
# Mark each play
for play in plays:
play['successful'] = self.calculator.is_successful(play)
# Overall
overall = self.calculator.calculate_success_rate(plays)
# By play type
pass_plays = [p for p in plays if p.get('play_type') == 'pass']
rush_plays = [p for p in plays if p.get('play_type') == 'rush']
# By down
by_down = {}
for down in [1, 2, 3, 4]:
down_plays = [p for p in plays if p['down'] == down]
if down_plays:
by_down[down] = self.calculator.calculate_success_rate(down_plays)
# Early downs vs late downs
early_down_plays = [p for p in plays if p['down'] <= 2]
late_down_plays = [p for p in plays if p['down'] >= 3]
# By distance category
short_plays = [p for p in plays if p['distance'] <= 3]
medium_plays = [p for p in plays if 3 < p['distance'] <= 7]
long_plays = [p for p in plays if p['distance'] > 7]
return {
'overall': overall,
'by_play_type': {
'passing': self.calculator.calculate_success_rate(pass_plays),
'rushing': self.calculator.calculate_success_rate(rush_plays)
},
'by_down': by_down,
'early_downs': self.calculator.calculate_success_rate(early_down_plays),
'late_downs': self.calculator.calculate_success_rate(late_down_plays),
'by_distance': {
'short (1-3)': self.calculator.calculate_success_rate(short_plays),
'medium (4-7)': self.calculator.calculate_success_rate(medium_plays),
'long (8+)': self.calculator.calculate_success_rate(long_plays)
}
}
11.3.4 Explosive Play Rate
A complement to success rate is explosive play rate—the percentage of plays that gain significant yardage.
Common Definitions:
- Explosive rush: 10+ yards
- Explosive pass: 15+ yards (some use 20+)
- Big play: 20+ yards (any play type)
def calculate_explosive_rate(plays: List[Dict],
rush_threshold: int = 10,
pass_threshold: int = 15) -> Dict:
"""
Calculate explosive play rates.
Args:
plays: List of play dictionaries
rush_threshold: Yards for explosive rush
pass_threshold: Yards for explosive pass
Returns:
Dictionary of explosive play metrics
"""
pass_plays = [p for p in plays if p.get('play_type') == 'pass']
rush_plays = [p for p in plays if p.get('play_type') == 'rush']
explosive_passes = sum(1 for p in pass_plays
if p['yards_gained'] >= pass_threshold)
explosive_rushes = sum(1 for p in rush_plays
if p['yards_gained'] >= rush_threshold)
return {
'explosive_pass_rate': explosive_passes / len(pass_plays) if pass_plays else 0,
'explosive_rush_rate': explosive_rushes / len(rush_plays) if rush_plays else 0,
'overall_explosive_rate': (explosive_passes + explosive_rushes) / len(plays) if plays else 0,
'explosive_passes': explosive_passes,
'explosive_rushes': explosive_rushes
}
11.4 Combining EPA and Success Rate
11.4.1 Why Both Metrics Matter
EPA and Success Rate measure different aspects of offensive performance:
- EPA measures total value—how many points you're generating
- Success Rate measures consistency—how often you're executing
A team can have high EPA with low success rate (big plays, lots of failures) or moderate EPA with high success rate (steady, efficient drives). The best offenses excel at both.
The Four Quadrants:
| Quadrant | High EPA | Low EPA |
|---|---|---|
| High Success Rate | Elite (consistent and explosive) | Grinding (efficient but limited ceiling) |
| Low Success Rate | Boom-or-bust (high variance) | Poor (inconsistent and inefficient) |
11.4.2 Comprehensive Efficiency Analysis
class ComprehensiveEfficiencyAnalyzer:
"""
Combine EPA and Success Rate for complete analysis.
"""
def __init__(self):
self.epa_calc = EPACalculator()
self.success_calc = SuccessRateCalculator()
def analyze_offense(self, plays: List[Dict]) -> Dict:
"""
Complete offensive efficiency analysis.
"""
# Calculate EPA for each play
for play in plays:
play['epa'] = self.epa_calc.calculate_play_epa(play)
play['successful'] = self.success_calc.is_successful(play)
# Overall metrics
total_epa = sum(p['epa'] for p in plays)
success_rate = sum(1 for p in plays if p['successful']) / len(plays)
# Separate pass and rush
pass_plays = [p for p in plays if p.get('play_type') == 'pass']
rush_plays = [p for p in plays if p.get('play_type') == 'rush']
# Pass analysis
pass_epa = sum(p['epa'] for p in pass_plays) / len(pass_plays) if pass_plays else 0
pass_success = sum(1 for p in pass_plays if p['successful']) / len(pass_plays) if pass_plays else 0
# Rush analysis
rush_epa = sum(p['epa'] for p in rush_plays) / len(rush_plays) if rush_plays else 0
rush_success = sum(1 for p in rush_plays if p['successful']) / len(rush_plays) if rush_plays else 0
# EPA on successful vs unsuccessful plays
successful_plays = [p for p in plays if p['successful']]
unsuccessful_plays = [p for p in plays if not p['successful']]
epa_when_successful = sum(p['epa'] for p in successful_plays) / len(successful_plays) if successful_plays else 0
epa_when_unsuccessful = sum(p['epa'] for p in unsuccessful_plays) / len(unsuccessful_plays) if unsuccessful_plays else 0
# Determine offensive style
style = self._classify_style(total_epa / len(plays), success_rate)
return {
'overall': {
'plays': len(plays),
'total_epa': round(total_epa, 1),
'epa_per_play': round(total_epa / len(plays), 3),
'success_rate': round(success_rate, 3)
},
'passing': {
'plays': len(pass_plays),
'epa_per_play': round(pass_epa, 3),
'success_rate': round(pass_success, 3)
},
'rushing': {
'plays': len(rush_plays),
'epa_per_play': round(rush_epa, 3),
'success_rate': round(rush_success, 3)
},
'epa_breakdown': {
'on_success': round(epa_when_successful, 3),
'on_failure': round(epa_when_unsuccessful, 3)
},
'style': style
}
def _classify_style(self, epa_per_play: float, success_rate: float) -> str:
"""Classify offensive style based on efficiency metrics."""
high_epa = epa_per_play > 0.10
high_success = success_rate > 0.45
if high_epa and high_success:
return 'Elite (efficient and explosive)'
elif high_epa and not high_success:
return 'Boom-or-bust (explosive but inconsistent)'
elif not high_epa and high_success:
return 'Grinding (consistent but limited)'
else:
return 'Struggling (needs improvement)'
11.4.3 EPA vs Success Rate Scatter Plot Analysis
Plotting teams on an EPA vs Success Rate scatter plot reveals offensive archetypes and identifies areas for improvement.
import matplotlib.pyplot as plt
def plot_efficiency_quadrants(teams: List[Dict], title: str = "Offensive Efficiency"):
"""
Create EPA vs Success Rate scatter plot.
Args:
teams: List of dictionaries with team, epa_per_play, success_rate
title: Chart title
"""
plt.figure(figsize=(10, 8))
epas = [t['epa_per_play'] for t in teams]
success_rates = [t['success_rate'] for t in teams]
names = [t['team'] for t in teams]
# Calculate averages for quadrant lines
avg_epa = sum(epas) / len(epas)
avg_success = sum(success_rates) / len(success_rates)
# Plot teams
plt.scatter(success_rates, epas, s=100, alpha=0.7)
# Add team labels
for i, name in enumerate(names):
plt.annotate(name, (success_rates[i], epas[i]),
fontsize=8, ha='center', va='bottom')
# Add quadrant lines
plt.axhline(y=avg_epa, color='gray', linestyle='--', alpha=0.5)
plt.axvline(x=avg_success, color='gray', linestyle='--', alpha=0.5)
# Labels
plt.xlabel('Success Rate', fontsize=12)
plt.ylabel('EPA per Play', fontsize=12)
plt.title(title, fontsize=14)
# Quadrant labels
plt.text(0.55, 0.25, 'Elite', fontsize=10, alpha=0.5)
plt.text(0.35, 0.25, 'Explosive', fontsize=10, alpha=0.5)
plt.text(0.55, -0.15, 'Grinding', fontsize=10, alpha=0.5)
plt.text(0.35, -0.15, 'Struggling', fontsize=10, alpha=0.5)
plt.tight_layout()
plt.show()
11.5 Applying Efficiency Metrics
11.5.1 Quarterback Evaluation
Efficiency metrics transform quarterback evaluation by accounting for situation and expectation.
class QuarterbackEPAAnalyzer:
"""
EPA-based quarterback analysis.
"""
def __init__(self):
self.epa_calc = EPACalculator()
def analyze_quarterback(self, dropbacks: List[Dict]) -> Dict:
"""
Comprehensive QB EPA analysis.
Args:
dropbacks: All dropbacks (passes, sacks, scrambles)
"""
# Calculate EPA for each play
for play in dropbacks:
play['epa'] = self.epa_calc.calculate_play_epa(play)
total_epa = sum(p['epa'] for p in dropbacks)
# Completions
completions = [p for p in dropbacks if p.get('completion')]
comp_epa = sum(p['epa'] for p in completions)
# Incompletions
incompletions = [p for p in dropbacks if p.get('incomplete')]
inc_epa = sum(p['epa'] for p in incompletions)
# Sacks
sacks = [p for p in dropbacks if p.get('sack')]
sack_epa = sum(p['epa'] for p in sacks)
# Interceptions
ints = [p for p in dropbacks if p.get('interception')]
int_epa = sum(p['epa'] for p in ints)
# Touchdowns
tds = [p for p in dropbacks if p.get('touchdown')]
td_epa = sum(p['epa'] for p in tds)
# By throw depth
short = [p for p in completions if p.get('air_yards', 0) < 10]
medium = [p for p in completions if 10 <= p.get('air_yards', 0) < 20]
deep = [p for p in completions if p.get('air_yards', 0) >= 20]
return {
'dropbacks': len(dropbacks),
'total_epa': round(total_epa, 1),
'epa_per_dropback': round(total_epa / len(dropbacks), 3),
'epa_breakdown': {
'completions': round(comp_epa, 1),
'incompletions': round(inc_epa, 1),
'sacks': round(sack_epa, 1),
'interceptions': round(int_epa, 1),
'touchdowns': round(td_epa, 1)
},
'by_depth': {
'short': round(sum(p['epa'] for p in short) / len(short), 3) if short else 0,
'medium': round(sum(p['epa'] for p in medium) / len(medium), 3) if medium else 0,
'deep': round(sum(p['epa'] for p in deep) / len(deep), 3) if deep else 0
},
'completion_rate': len(completions) / len(dropbacks)
}
11.5.2 Running Back Evaluation
class RunningBackEfficiencyAnalyzer:
"""
EPA and success rate analysis for running backs.
"""
def __init__(self):
self.epa_calc = EPACalculator()
self.success_calc = SuccessRateCalculator()
def analyze_running_back(self, carries: List[Dict]) -> Dict:
"""
Complete RB efficiency analysis.
"""
for carry in carries:
carry['epa'] = self.epa_calc.calculate_play_epa(carry)
carry['successful'] = self.success_calc.is_successful(carry)
total_epa = sum(c['epa'] for c in carries)
success_rate = sum(1 for c in carries if c['successful']) / len(carries)
# By run direction (if available)
directions = {}
for direction in ['left', 'middle', 'right']:
dir_carries = [c for c in carries if c.get('direction') == direction]
if dir_carries:
directions[direction] = {
'carries': len(dir_carries),
'epa_per_carry': round(sum(c['epa'] for c in dir_carries) / len(dir_carries), 3),
'success_rate': round(sum(1 for c in dir_carries if c['successful']) / len(dir_carries), 3)
}
# By box count (if available)
light_box = [c for c in carries if c.get('defenders_in_box', 7) <= 6]
stacked_box = [c for c in carries if c.get('defenders_in_box', 7) >= 8]
# Explosive runs
explosive = sum(1 for c in carries if c.get('yards_gained', 0) >= 10)
return {
'carries': len(carries),
'total_epa': round(total_epa, 1),
'epa_per_carry': round(total_epa / len(carries), 3),
'success_rate': round(success_rate, 3),
'explosive_rate': round(explosive / len(carries), 3),
'by_direction': directions,
'vs_light_box': {
'carries': len(light_box),
'epa': round(sum(c['epa'] for c in light_box) / len(light_box), 3) if light_box else 0
},
'vs_stacked_box': {
'carries': len(stacked_box),
'epa': round(sum(c['epa'] for c in stacked_box) / len(stacked_box), 3) if stacked_box else 0
}
}
11.5.3 Team Comparison and Ranking
def rank_teams_by_efficiency(team_data: List[Dict]) -> pd.DataFrame:
"""
Rank teams by efficiency metrics.
Args:
team_data: List of team efficiency dictionaries
Returns:
DataFrame with team rankings
"""
df = pd.DataFrame(team_data)
# Calculate composite efficiency score
df['epa_rank'] = df['epa_per_play'].rank(ascending=False)
df['success_rank'] = df['success_rate'].rank(ascending=False)
# Composite (average of ranks, lower is better)
df['composite_rank'] = (df['epa_rank'] + df['success_rank']) / 2
# Sort by composite
df = df.sort_values('composite_rank')
# Add tier labels
n_teams = len(df)
df['tier'] = pd.cut(df['composite_rank'],
bins=[0, n_teams*0.2, n_teams*0.4, n_teams*0.6, n_teams*0.8, n_teams+1],
labels=['Elite', 'Good', 'Average', 'Below Average', 'Poor'])
return df[['team', 'epa_per_play', 'success_rate', 'composite_rank', 'tier']]
11.6 Limitations and Considerations
11.6.1 Sample Size Concerns
Efficiency metrics require adequate sample sizes to be reliable.
Minimum Sample Recommendations:
| Analysis | Minimum Sample | Ideal Sample |
|---|---|---|
| Team EPA | 100 plays | 400+ plays |
| QB EPA | 75 dropbacks | 200+ dropbacks |
| RB EPA | 50 carries | 150+ carries |
| Situational | 30 plays | 100+ plays |
11.6.2 Context Sensitivity
EPA and success rate don't capture all context:
- Score differential - Strategies change when leading/trailing
- Time remaining - Late-game plays have different implications
- Opponent quality - Playing elite defense vs poor defense
- Weather conditions - May affect passing vs rushing value
11.6.3 Credit Assignment
A completion for 15 yards involves: - The quarterback's throw - The receiver's route running and catch - The offensive line's protection - The play design
EPA credits this to "the play" but assigning value to individual contributors requires additional analysis.
11.7 Summary
Expected Points Added and Success Rate have transformed football analysis from counting statistics to measuring true value. EPA answers "how much did this play help?" while Success Rate answers "how often did we execute?"
Together, these metrics provide a comprehensive view of offensive (and defensive) efficiency. They enable:
- Fair comparisons across different game situations
- Identification of true impact players
- Strategic optimization
- Predictive modeling
The best analysts use these metrics as a foundation, then layer in additional context—opponent quality, game situation, and individual credit assignment—to build complete understanding.
Key Concepts Summary
| Concept | Definition | Key Insight |
|---|---|---|
| Expected Points | Point value of a game state | Converts field position to numbers |
| EPA | Change in EP from a play | Measures true play value |
| Success Rate | % of plays meeting threshold | Measures consistency |
| Explosive Rate | % of big-gain plays | Measures ceiling |
| Down adjustment | EP changes by down | Each down costs ~0.4 EP |
Further Reading
- Burke, Brian. "Expected Points and Expected Points Added"
- Baldwin, Ben. "Introduction to Expected Points"
- cfbfastR documentation on EPA calculation
- nflfastR expected points methodology
Next Chapter Preview
In Chapter 12, we transition from calculating metrics to visualizing them. You'll learn how to create compelling visualizations that communicate efficiency metrics effectively to technical and non-technical audiences alike.