In Part 2, we evaluated individual players using EPA and related metrics. Now we shift perspective to the team level, asking: How do individual performances combine to create team success?
In This Chapter
- Learning Objectives
- Introduction: From Individual to Team
- Team EPA: The Foundation
- Success Rate: Measuring Consistency
- Explosiveness: Measuring Big Plays
- The Success-Explosiveness Framework
- Efficiency vs Wins
- Pass vs Rush Efficiency
- Defensive Efficiency Metrics
- Building a Composite Rating
- Stability and Predictiveness
- Limitations of Efficiency Metrics
- Summary
- Preview: Chapter 13
Chapter 12: Team Efficiency Metrics
Learning Objectives
By the end of this chapter, you will be able to:
- Calculate and interpret team-level EPA metrics
- Understand the relationship between efficiency and winning
- Apply success rate as a consistency measure
- Evaluate teams using explosiveness metrics
- Build composite team efficiency ratings
- Understand the limitations of efficiency metrics
- Compare efficiency across offensive and defensive units
Introduction: From Individual to Team
In Part 2, we evaluated individual players using EPA and related metrics. Now we shift perspective to the team level, asking: How do individual performances combine to create team success?
This question is deceptively complex. A team of talented individuals doesn't automatically produce a talented team. Chemistry, scheme fit, coaching, and coordination all matter. Team efficiency metrics attempt to capture overall unit performance while abstracting away individual contributions.
Why Team Metrics Matter
Roster Construction: Understanding which efficiency metrics drive wins helps front offices prioritize investments.
Opponent Preparation: Coaches need to understand opposing team tendencies and strengths.
Performance Evaluation: Did the team perform well, or did they get lucky? Efficiency helps separate process from outcome.
Predictive Value: Efficiency metrics predict future performance better than win-loss records alone.
Team EPA: The Foundation
Calculating Team Offensive EPA
Team offensive EPA aggregates all plays where the team has possession:
import nfl_data_py as nfl
import pandas as pd
import numpy as np
# Load data
pbp = nfl.import_pbp_data([2023])
# Filter to regular plays
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
].copy()
# Calculate team offensive EPA
team_offense = (plays
.groupby('posteam')
.agg(
plays=('epa', 'count'),
total_epa=('epa', 'sum'),
epa_per_play=('epa', 'mean'),
pass_epa=('epa', lambda x: x[plays.loc[x.index, 'pass_attempt'] == 1].mean()),
rush_epa=('epa', lambda x: x[plays.loc[x.index, 'rush_attempt'] == 1].mean()),
success_rate=('epa', lambda x: (x > 0).mean())
)
.sort_values('epa_per_play', ascending=False)
)
print("Team Offensive EPA Rankings:")
print(team_offense.round(3).to_string())
Calculating Team Defensive EPA
Defensive EPA is the EPA allowed to opponents:
# Calculate team defensive EPA
team_defense = (plays
.groupby('defteam')
.agg(
plays=('epa', 'count'),
total_epa_allowed=('epa', 'sum'),
epa_allowed_per_play=('epa', 'mean'),
pass_epa_allowed=('epa', lambda x: x[plays.loc[x.index, 'pass_attempt'] == 1].mean()),
rush_epa_allowed=('epa', lambda x: x[plays.loc[x.index, 'rush_attempt'] == 1].mean()),
success_rate_allowed=('epa', lambda x: (x > 0).mean())
)
.sort_values('epa_allowed_per_play') # Lower is better
)
print("Team Defensive EPA Rankings (lower is better):")
print(team_defense.round(3).to_string())
Net EPA: Combining Offense and Defense
# Combine for net EPA
team_net = pd.DataFrame({
'off_epa': team_offense['epa_per_play'],
'def_epa': team_defense['epa_allowed_per_play'],
})
team_net['net_epa'] = team_net['off_epa'] - team_net['def_epa']
team_net = team_net.sort_values('net_epa', ascending=False)
print("Net EPA Rankings:")
print(team_net.round(3).to_string())
Interpretation:
| Net EPA | Team Quality |
|---|---|
| > 0.15 | Elite |
| 0.08 to 0.15 | Very Good |
| 0.00 to 0.08 | Above Average |
| -0.08 to 0.00 | Below Average |
| < -0.08 | Poor |
Success Rate: Measuring Consistency
Why Success Rate Matters
EPA captures magnitude but not consistency. A team that gains 10 yards, then loses 5, then gains 10, then loses 5 has the same total yards as a team gaining 5 every play - but very different characteristics.
Success Rate = Percentage of plays with EPA > 0
def calculate_success_rate(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Calculate success rate for all teams.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
success = (plays
.groupby('posteam')
.agg(
plays=('epa', 'count'),
successes=('epa', lambda x: (x > 0).sum()),
success_rate=('epa', lambda x: (x > 0).mean()),
epa_per_play=('epa', 'mean')
)
.sort_values('success_rate', ascending=False)
)
return success
success_rates = calculate_success_rate(pbp)
print("Success Rate Rankings:")
print(success_rates.round(3).to_string())
Success Rate by Down
Success requirements vary by down:
def success_rate_by_down(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Calculate success rate by down.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna()) &
(pbp['down'].isin([1, 2, 3]))
]
by_down = (plays
.groupby(['posteam', 'down'])
.agg(
plays=('epa', 'count'),
success_rate=('epa', lambda x: (x > 0).mean())
)
.reset_index()
)
# Pivot for comparison
pivot = by_down.pivot(index='posteam', columns='down', values='success_rate')
pivot.columns = ['1st_down', '2nd_down', '3rd_down']
return pivot
down_success = success_rate_by_down(pbp)
print("Success Rate by Down:")
print(down_success.round(3).to_string())
Typical Success Rates:
| Down | League Average | Good | Elite |
|---|---|---|---|
| 1st | 48% | 52%+ | 55%+ |
| 2nd | 42% | 46%+ | 50%+ |
| 3rd | 38% | 42%+ | 46%+ |
Explosiveness: Measuring Big Plays
Explosive Play Rate
While success rate measures consistency, explosiveness measures the ability to generate big plays:
def calculate_explosiveness(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Calculate explosive play metrics.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
explosive = (plays
.groupby('posteam')
.agg(
plays=('epa', 'count'),
# Explosive plays: 20+ pass, 10+ rush
explosive_passes=('yards_gained', lambda x: (
(plays.loc[x.index, 'pass_attempt'] == 1) & (x >= 20)
).sum()),
explosive_rushes=('yards_gained', lambda x: (
(plays.loc[x.index, 'rush_attempt'] == 1) & (x >= 10)
).sum()),
# High EPA plays
high_epa_plays=('epa', lambda x: (x > 1.0).sum())
)
)
explosive['explosive_rate'] = (
(explosive['explosive_passes'] + explosive['explosive_rushes']) /
explosive['plays']
)
explosive['high_epa_rate'] = explosive['high_epa_plays'] / explosive['plays']
return explosive.sort_values('explosive_rate', ascending=False)
explosiveness = calculate_explosiveness(pbp)
print("Explosiveness Rankings:")
print(explosiveness[['explosive_rate', 'high_epa_rate']].round(3).to_string())
EPA Per Successful Play
Another explosiveness measure: how much value do you generate when you succeed?
def epa_per_success(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Calculate EPA on successful plays only.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
successful = plays[plays['epa'] > 0]
epa_success = (successful
.groupby('posteam')
.agg(
successes=('epa', 'count'),
epa_per_success=('epa', 'mean'),
median_success_epa=('epa', 'median')
)
)
return epa_success.sort_values('epa_per_success', ascending=False)
epa_when_successful = epa_per_success(pbp)
print("EPA Per Successful Play:")
print(epa_when_successful.round(3).to_string())
The Success-Explosiveness Framework
Quadrant Analysis
Teams can be categorized by their success rate and explosiveness:
def team_quadrant_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Categorize teams by consistency and explosiveness.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
team_metrics = (plays
.groupby('posteam')
.agg(
success_rate=('epa', lambda x: (x > 0).mean()),
explosive_rate=('yards_gained', lambda x: (
((plays.loc[x.index, 'pass_attempt'] == 1) & (x >= 20)) |
((plays.loc[x.index, 'rush_attempt'] == 1) & (x >= 10))
).mean())
)
)
# Determine quadrant
median_success = team_metrics['success_rate'].median()
median_explosive = team_metrics['explosive_rate'].median()
def assign_quadrant(row):
if row['success_rate'] >= median_success and row['explosive_rate'] >= median_explosive:
return 'Elite (High Both)'
elif row['success_rate'] >= median_success:
return 'Consistent'
elif row['explosive_rate'] >= median_explosive:
return 'Explosive'
else:
return 'Struggling'
team_metrics['quadrant'] = team_metrics.apply(assign_quadrant, axis=1)
return team_metrics
quadrants = team_quadrant_analysis(pbp)
print("Team Quadrant Analysis:")
print(quadrants.to_string())
# Count by quadrant
print("\nQuadrant Distribution:")
print(quadrants['quadrant'].value_counts())
Quadrant Interpretation:
| Quadrant | Characteristics | Example Style |
|---|---|---|
| Elite | High success + high explosiveness | Complete offense |
| Consistent | High success, moderate explosiveness | Ball control |
| Explosive | Moderate success, high explosiveness | Big play dependent |
| Struggling | Low both | Needs improvement |
Efficiency vs Wins
The Correlation Question
How well does efficiency predict winning?
def efficiency_vs_wins(pbp: pd.DataFrame) -> dict:
"""
Correlate efficiency metrics with win percentage.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
# Team efficiency
team_efficiency = (plays
.groupby('posteam')
.agg(
off_epa=('epa', 'mean'),
success_rate=('epa', lambda x: (x > 0).mean())
)
)
# Defense efficiency
def_efficiency = (plays
.groupby('defteam')
.agg(
def_epa=('epa', 'mean')
)
)
# Combine
team_efficiency = team_efficiency.join(def_efficiency)
team_efficiency['net_epa'] = team_efficiency['off_epa'] - team_efficiency['def_epa']
# Calculate win percentage (from game results)
games = pbp.groupby(['game_id', 'home_team', 'away_team', 'home_score', 'away_score']).size().reset_index()
games = games.drop_duplicates(subset='game_id')
# Home wins
home_wins = games[games['home_score'] > games['away_score']].groupby('home_team').size()
away_wins = games[games['away_score'] > games['home_score']].groupby('away_team').size()
home_games = games.groupby('home_team').size()
away_games = games.groupby('away_team').size()
wins = home_wins.add(away_wins, fill_value=0)
total_games = home_games.add(away_games, fill_value=0)
win_pct = wins / total_games
team_efficiency['win_pct'] = win_pct
# Correlations
correlations = {
'off_epa_vs_wins': team_efficiency['off_epa'].corr(team_efficiency['win_pct']),
'def_epa_vs_wins': team_efficiency['def_epa'].corr(team_efficiency['win_pct']),
'net_epa_vs_wins': team_efficiency['net_epa'].corr(team_efficiency['win_pct']),
'success_rate_vs_wins': team_efficiency['success_rate'].corr(team_efficiency['win_pct'])
}
return correlations, team_efficiency
correlations, team_data = efficiency_vs_wins(pbp)
print("Efficiency vs Wins Correlations:")
for metric, corr in correlations.items():
print(f" {metric}: r = {corr:.3f}")
Typical Correlations:
| Metric | Correlation with Wins |
|---|---|
| Net EPA/play | ~0.75-0.85 |
| Offensive EPA | ~0.55-0.65 |
| Defensive EPA allowed | ~-0.50 to -0.60 |
| Success Rate | ~0.65-0.75 |
| Point Differential | ~0.90+ |
The sign on Defensive EPA allowed is negative: a defense that allows fewer expected points per play (a more negative EPA-allowed value) wins more games, so lower is better. Offensive EPA carries the opposite sign — higher is better. When you combine the two into Net EPA, subtract defensive EPA allowed from offensive EPA so both contributions push wins in the same direction.
Net EPA is highly predictive of wins, but not perfect. The gap is explained by: - Special teams - Turnover luck - Close game variance - Red zone efficiency
Pass vs Rush Efficiency
The Modern NFL Reality
Passing is more efficient than rushing in the modern NFL:
def pass_vs_rush_efficiency(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Compare pass and rush efficiency.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
comparison = (plays
.groupby('posteam')
.agg(
pass_epa=('epa', lambda x: x[plays.loc[x.index, 'pass_attempt'] == 1].mean()),
rush_epa=('epa', lambda x: x[plays.loc[x.index, 'rush_attempt'] == 1].mean()),
pass_success=('epa', lambda x: (x[plays.loc[x.index, 'pass_attempt'] == 1] > 0).mean()),
rush_success=('epa', lambda x: (x[plays.loc[x.index, 'rush_attempt'] == 1] > 0).mean()),
pass_rate=('pass_attempt', 'mean')
)
)
comparison['pass_rush_gap'] = comparison['pass_epa'] - comparison['rush_epa']
return comparison.sort_values('pass_rush_gap', ascending=False)
pass_rush = pass_vs_rush_efficiency(pbp)
print("Pass vs Rush Efficiency:")
print(pass_rush.round(3).to_string())
print(f"\nLeague Average Pass EPA: {pass_rush['pass_epa'].mean():.3f}")
print(f"League Average Rush EPA: {pass_rush['rush_epa'].mean():.3f}")
print(f"Pass Advantage: {pass_rush['pass_epa'].mean() - pass_rush['rush_epa'].mean():.3f}")
Key Insight: League-wide, passing generates approximately 0.05-0.08 more EPA per play than rushing. This "pass premium" has implications for optimal play calling (covered in Chapter 13).
Defensive Efficiency Metrics
Pass Defense vs Run Defense
def defensive_efficiency_breakdown(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Break down defensive efficiency by play type.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
defense = (plays
.groupby('defteam')
.agg(
total_epa=('epa', 'mean'),
pass_epa=('epa', lambda x: x[plays.loc[x.index, 'pass_attempt'] == 1].mean()),
rush_epa=('epa', lambda x: x[plays.loc[x.index, 'rush_attempt'] == 1].mean()),
success_allowed=('epa', lambda x: (x > 0).mean()),
explosive_allowed=('epa', lambda x: (x > 1.0).mean())
)
.sort_values('total_epa')
)
# Which phase contributes more to this defense's overall impact?
# Use absolute magnitudes so pass_weight is an interpretable share in
# [0, 1]: the fraction of total defensive EPA magnitude coming from
# pass defense. (Defensive pass_epa/rush_epa are signed, with negative
# = good defense, so dividing the signed value directly would flip sign
# with defense quality and fall outside [0, 1].)
defense['pass_weight'] = defense['pass_epa'].abs() / (defense['pass_epa'].abs() + defense['rush_epa'].abs())
return defense
defensive = defensive_efficiency_breakdown(pbp)
print("Defensive Efficiency Breakdown:")
print(defensive.round(3).to_string())
Relative Pass vs Rush Defense
Some defenses excel against one attack but not the other:
def defensive_balance(pbp: pd.DataFrame) -> pd.DataFrame:
"""
Analyze defensive balance between pass and run.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
defense = (plays
.groupby('defteam')
.agg(
pass_epa=('epa', lambda x: x[plays.loc[x.index, 'pass_attempt'] == 1].mean()),
rush_epa=('epa', lambda x: x[plays.loc[x.index, 'rush_attempt'] == 1].mean())
)
)
# Rank within each category
defense['pass_rank'] = defense['pass_epa'].rank()
defense['rush_rank'] = defense['rush_epa'].rank()
defense['balance'] = abs(defense['pass_rank'] - defense['rush_rank'])
# Categorize
def categorize(row):
if row['pass_rank'] <= 10 and row['rush_rank'] <= 10:
return 'Elite Overall'
elif row['pass_rank'] <= 10:
return 'Pass Defense Specialist'
elif row['rush_rank'] <= 10:
return 'Run Defense Specialist'
else:
return 'Below Average'
defense['category'] = defense.apply(categorize, axis=1)
return defense
balance = defensive_balance(pbp)
print("Defensive Balance:")
print(balance[['pass_epa', 'rush_epa', 'category']].round(3).to_string())
Building a Composite Rating
Weighted Efficiency Rating
Combining multiple metrics into a single rating:
from dataclasses import dataclass
from typing import Dict, List
@dataclass
class TeamEfficiencyReport:
"""Complete team efficiency evaluation."""
team: str
season: int
# Offense
off_epa: float
off_success_rate: float
off_explosive_rate: float
off_rank: int
# Defense
def_epa: float
def_success_allowed: float
def_explosive_allowed: float
def_rank: int
# Overall
net_epa: float
composite_score: float
overall_rank: int
class TeamEfficiencyEvaluator:
"""
Comprehensive team efficiency evaluation system.
"""
def __init__(self, pbp: pd.DataFrame, season: int = 2023):
self.pbp = pbp
self.season = season
self.plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
].copy()
self._calculate_all_teams()
def _calculate_all_teams(self):
"""Calculate metrics for all teams."""
# Offensive metrics
self.offense = (self.plays
.groupby('posteam')
.agg(
off_epa=('epa', 'mean'),
off_success=('epa', lambda x: (x > 0).mean()),
off_explosive=('yards_gained', lambda x: (
((self.plays.loc[x.index, 'pass_attempt'] == 1) & (x >= 20)) |
((self.plays.loc[x.index, 'rush_attempt'] == 1) & (x >= 10))
).mean())
)
)
# Defensive metrics
self.defense = (self.plays
.groupby('defteam')
.agg(
def_epa=('epa', 'mean'),
def_success=('epa', lambda x: (x > 0).mean()),
def_explosive=('epa', lambda x: (x > 1.0).mean())
)
)
# Combine
self.combined = self.offense.join(self.defense)
self.combined['net_epa'] = self.combined['off_epa'] - self.combined['def_epa']
# Calculate composite score (0-100)
self._calculate_composite()
def _calculate_composite(self):
"""Calculate composite efficiency score."""
# Normalize each metric to 0-100
def normalize(series, higher_is_better=True):
if higher_is_better:
return (series - series.min()) / (series.max() - series.min()) * 100
else:
return (series.max() - series) / (series.max() - series.min()) * 100
self.combined['off_score'] = normalize(self.combined['off_epa'])
self.combined['def_score'] = normalize(self.combined['def_epa'], higher_is_better=False)
self.combined['success_score'] = normalize(self.combined['off_success'])
self.combined['def_success_score'] = normalize(self.combined['def_success'], higher_is_better=False)
# Weighted composite
# 35% offense EPA, 35% defense EPA, 15% offensive success, 15% defensive success
self.combined['composite'] = (
self.combined['off_score'] * 0.35 +
self.combined['def_score'] * 0.35 +
self.combined['success_score'] * 0.15 +
self.combined['def_success_score'] * 0.15
)
# Rankings
self.combined['off_rank'] = self.combined['off_epa'].rank(ascending=False).astype(int)
self.combined['def_rank'] = self.combined['def_epa'].rank(ascending=True).astype(int)
self.combined['overall_rank'] = self.combined['composite'].rank(ascending=False).astype(int)
def evaluate_team(self, team: str) -> TeamEfficiencyReport:
"""Generate efficiency report for a team."""
row = self.combined.loc[team]
return TeamEfficiencyReport(
team=team,
season=self.season,
off_epa=row['off_epa'],
off_success_rate=row['off_success'],
off_explosive_rate=row['off_explosive'],
off_rank=int(row['off_rank']),
def_epa=row['def_epa'],
def_success_allowed=row['def_success'],
def_explosive_allowed=row['def_explosive'],
def_rank=int(row['def_rank']),
net_epa=row['net_epa'],
composite_score=row['composite'],
overall_rank=int(row['overall_rank'])
)
def rank_all_teams(self) -> pd.DataFrame:
"""Return all teams ranked by composite score."""
return self.combined.sort_values('composite', ascending=False)[
['off_epa', 'def_epa', 'net_epa', 'composite', 'overall_rank']
]
def generate_report(self, team: str) -> str:
"""Generate text report for team."""
r = self.evaluate_team(team)
lines = [
f"\n{'='*60}",
f"TEAM EFFICIENCY REPORT: {team}",
f"Season: {self.season}",
f"{'='*60}",
"",
f"OVERALL: Rank #{r.overall_rank} of 32 (Score: {r.composite_score:.1f}/100)",
f" Net EPA/Play: {r.net_epa:+.3f}",
"",
f"OFFENSE: Rank #{r.off_rank}",
f" EPA/Play: {r.off_epa:+.3f}",
f" Success Rate: {r.off_success_rate:.1%}",
f" Explosive Rate: {r.off_explosive_rate:.1%}",
"",
f"DEFENSE: Rank #{r.def_rank}",
f" EPA Allowed: {r.def_epa:+.3f}",
f" Success Allowed: {r.def_success_allowed:.1%}",
f" Explosive Allowed: {r.def_explosive_allowed:.1%}",
f"{'='*60}"
]
return "\n".join(lines)
# Example usage
evaluator = TeamEfficiencyEvaluator(pbp, season=2023)
# Rank all teams
print("Team Efficiency Rankings:")
print(evaluator.rank_all_teams().round(3).to_string())
# Generate report for specific team
print(evaluator.generate_report('KC'))
Stability and Predictiveness
Which Metrics Persist?
Some efficiency metrics are more stable year-to-year than others:
def metric_stability(years: list) -> dict:
"""
Calculate year-to-year stability for various metrics.
"""
pbp = nfl.import_pbp_data(years)
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
yearly_metrics = {}
for year in years:
year_plays = plays[plays['season'] == year]
metrics = (year_plays
.groupby('posteam')
.agg(
epa=('epa', 'mean'),
success=('epa', lambda x: (x > 0).mean()),
pass_epa=('epa', lambda x: x[year_plays.loc[x.index, 'pass_attempt'] == 1].mean()),
rush_epa=('epa', lambda x: x[year_plays.loc[x.index, 'rush_attempt'] == 1].mean())
)
)
yearly_metrics[year] = metrics
# Calculate correlations
correlations = {}
for i in range(len(years) - 1):
y1, y2 = years[i], years[i+1]
common = yearly_metrics[y1].index.intersection(yearly_metrics[y2].index)
for metric in ['epa', 'success', 'pass_epa', 'rush_epa']:
key = f"{metric}_{y1}_{y2}"
correlations[key] = yearly_metrics[y1].loc[common, metric].corr(
yearly_metrics[y2].loc[common, metric]
)
return correlations
# Calculate stability
stability = metric_stability([2022, 2023])
print("Year-to-Year Metric Stability:")
for metric, corr in stability.items():
print(f" {metric}: r = {corr:.3f}")
Typical Stability (r values):
| Metric | Year-to-Year Correlation |
|---|---|
| Offensive EPA | 0.50-0.60 |
| Defensive EPA | 0.40-0.50 |
| Pass EPA | 0.55-0.65 |
| Rush EPA | 0.25-0.35 |
| Success Rate | 0.45-0.55 |
Key Insight: Offensive metrics, especially passing, are more stable than defensive or rushing metrics. This has implications for roster building and projections.
Limitations of Efficiency Metrics
What They Miss
- Situational Context: Down, distance, and score affect optimal plays
- Opponent Adjustment: Raw EPA doesn't account for opponent strength
- Weather and Environment: Outdoor conditions affect efficiency
- Injury Effects: Missing players change team capability
- Garbage Time: Late-game blowouts inflate or deflate metrics
Addressing Limitations
def filtered_efficiency(pbp: pd.DataFrame, filter_garbage_time: bool = True) -> pd.DataFrame:
"""
Calculate efficiency with optional garbage time filter.
"""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
].copy()
if filter_garbage_time:
# Remove plays where win probability is very high or low
plays = plays[
(plays['wp'] >= 0.05) &
(plays['wp'] <= 0.95)
]
efficiency = (plays
.groupby('posteam')
.agg(
plays=('epa', 'count'),
epa=('epa', 'mean'),
success=('epa', lambda x: (x > 0).mean())
)
)
return efficiency
# Compare with and without garbage time filter
all_plays = filtered_efficiency(pbp, filter_garbage_time=False)
filtered = filtered_efficiency(pbp, filter_garbage_time=True)
comparison = pd.DataFrame({
'all_epa': all_plays['epa'],
'filtered_epa': filtered['epa'],
'difference': filtered['epa'] - all_plays['epa']
})
print("Garbage Time Impact:")
print(comparison.round(3).to_string())
Summary
Key Concepts
- Team EPA aggregates individual play values into team-level performance
- Success Rate measures consistency independent of magnitude
- Explosiveness captures big-play ability
- Net EPA (offense - defense) strongly correlates with winning
- Passing efficiency exceeds rushing efficiency league-wide
- Stability varies by metric - passing is more stable than rushing
Practical Applications
- Team Comparison: Use composite ratings to compare overall quality
- Strength Identification: Quadrant analysis reveals team style
- Predictive Modeling: EPA-based metrics forecast future performance
- Roster Building: Invest in metrics that are stable and valuable
Preview: Chapter 13
Next, we'll explore Pace and Play Calling - examining how teams choose between pass and run, how pace affects efficiency, and whether teams make optimal decisions.