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 | ~0.50-0.60 |
| Success Rate | ~0.65-0.75 |
| Point Differential | ~0.90+ |
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 is more important?
defense['pass_weight'] = defense['pass_epa'] / (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.