5 min read

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?

Chapter 12: Team Efficiency Metrics

Learning Objectives

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

  1. Calculate and interpret team-level EPA metrics
  2. Understand the relationship between efficiency and winning
  3. Apply success rate as a consistency measure
  4. Evaluate teams using explosiveness metrics
  5. Build composite team efficiency ratings
  6. Understand the limitations of efficiency metrics
  7. 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

  1. Situational Context: Down, distance, and score affect optimal plays
  2. Opponent Adjustment: Raw EPA doesn't account for opponent strength
  3. Weather and Environment: Outdoor conditions affect efficiency
  4. Injury Effects: Missing players change team capability
  5. 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

  1. Team EPA aggregates individual play values into team-level performance
  2. Success Rate measures consistency independent of magnitude
  3. Explosiveness captures big-play ability
  4. Net EPA (offense - defense) strongly correlates with winning
  5. Passing efficiency exceeds rushing efficiency league-wide
  6. 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.