Key Takeaways: Advanced Passing Metrics
Quick Reference Card
Core Advanced Metrics
| Metric | Formula | Interpretation |
|---|---|---|
| EPA | EP After - EP Before | Value added per play |
| CPOE | Actual Comp% - Expected Comp% | Accuracy above/below expectation |
| aDOT | Total Air Yards / Attempts | Average throw depth |
| IAY/PA | Intended Air Yards / Attempts | Aggressiveness measure |
| CAY/PA | Completed Air Yards / Attempts | Successful downfield passing |
Essential Formulas
Expected Points Added (EPA)
def calculate_epa(ep_before: float, ep_after: float) -> float:
"""Calculate Expected Points Added for a play."""
return ep_after - ep_before
# Special cases:
# Touchdown: EP After = 7.0
# Interception: EP After = -(opponent's EP at their starting position)
# Incomplete: EP After = EP at same yard line, next down
Completion Probability (Simplified)
def completion_probability(air_yards: float,
pressure: bool = False,
third_down: bool = False) -> float:
"""
Estimate completion probability.
Production models include more features.
"""
# Logistic regression coefficients (simplified)
log_odds = 2.0 # Intercept
log_odds -= 0.08 * air_yards
log_odds -= 0.6 * int(pressure)
log_odds -= 0.2 * int(third_down)
# Sigmoid function
probability = 1 / (1 + np.exp(-log_odds))
return probability
CPOE Calculation
def calculate_cpoe(passes: List[Dict]) -> Dict:
"""Calculate Completion Percentage Over Expected."""
expected = sum(p['completion_prob'] for p in passes)
actual = sum(1 for p in passes if p['completed'])
expected_pct = expected / len(passes) * 100
actual_pct = actual / len(passes) * 100
cpoe = actual_pct - expected_pct
return {
'expected_comp_pct': expected_pct,
'actual_comp_pct': actual_pct,
'cpoe': cpoe
}
Air Yards Metrics
def calculate_air_yards_metrics(passes: List[Dict]) -> Dict:
"""Calculate comprehensive air yards metrics."""
total_air_yards = sum(p['air_yards'] for p in passes)
completed_air_yards = sum(p['air_yards'] for p in passes if p['completed'])
completed = [p for p in passes if p['completed']]
total_yac = sum(p['yards'] - p['air_yards'] for p in completed
if p['yards'] > p['air_yards'])
total_yards = sum(p['yards'] for p in completed)
return {
'adot': total_air_yards / len(passes),
'iay_pa': total_air_yards / len(passes), # Same as aDOT
'cay_pa': completed_air_yards / len(passes),
'avg_yac': total_yac / len(completed) if completed else 0,
'air_yards_share': completed_air_yards / total_yards * 100 if total_yards else 0
}
Metric Benchmarks (FBS Quarterbacks)
EPA Benchmarks
| Rating | EPA per Dropback |
|---|---|
| Elite | > +0.25 |
| Good | +0.10 to +0.25 |
| Average | -0.05 to +0.10 |
| Below Average | -0.15 to -0.05 |
| Poor | < -0.15 |
CPOE Benchmarks
| Rating | CPOE |
|---|---|
| Elite | > +6% |
| Good | +3% to +6% |
| Average | -2% to +3% |
| Below Average | -5% to -2% |
| Poor | < -5% |
Air Yards Benchmarks
| Style | aDOT | Interpretation |
|---|---|---|
| Aggressive | > 10.0 | Deep passing attack |
| Balanced | 8.0 - 10.0 | Standard NFL-style |
| Conservative | 6.0 - 8.0 | Short/intermediate focus |
| Check-down heavy | < 6.0 | Very conservative |
Key Concepts to Remember
1. Why Traditional Stats Fall Short
- Completion % doesn't account for throw difficulty
- Yards don't consider game situation
- TD/INT ratio ignores context (garbage time, desperation throws)
- All traditional stats miss play-by-play value
2. EPA Captures Context
EPA values the same play differently based on: - Down and distance - Field position - Score differential - Time remaining
3. CPOE Reveals True Accuracy
High Comp% + Low CPOE = Easy throws, average accuracy
Low Comp% + High CPOE = Difficult throws, elite accuracy
4. Air Yards Tell the Story
- High aDOT: Aggressive, takes chances
- High YAC Share: Short passes, relies on playmakers
- Low air yards share: Ball placement creates YAC
5. Pressure Changes Everything
Typical performance drops under pressure: - Completion %: -15% to -20% - EPA: Positive → Negative - Must separate QB skill from line quality
Quick Analysis Workflow
Step 1: Calculate Traditional Baseline
traditional = {
'comp_pct': completions / attempts * 100,
'ypa': yards / attempts,
'td_pct': touchdowns / attempts * 100,
'int_pct': interceptions / attempts * 100
}
Step 2: Add Advanced Context
advanced = {
'cpoe': actual_comp_pct - expected_comp_pct,
'epa_per_dropback': total_epa / dropbacks,
'adot': total_air_yards / attempts,
'success_rate': positive_epa_plays / total_plays * 100
}
Step 3: Analyze Splits
splits = {
'clean_pocket': filter_clean_pocket(passes),
'under_pressure': filter_pressured(passes),
'early_down': filter_downs(passes, [1, 2]),
'late_down': filter_downs(passes, [3, 4]),
'first_half': filter_half(passes, 1),
'second_half': filter_half(passes, 2)
}
Step 4: Draw Conclusions
- Compare CPOE to raw completion %
- Check EPA consistency across situations
- Identify pressure impact
- Evaluate depth of target trends
Common Mistakes to Avoid
-
Using EPA without sample size consideration - Small samples are unreliable - Minimum ~100 dropbacks for stable metrics
-
Ignoring expected completion probability - A 5-yard checkdown ≠ 5-yard comeback route - Always compare to expectation
-
Crediting/blaming QB for all passing results - Receivers affect YAC - O-line affects pressure - Play-calling affects opportunity
-
Comparing across eras without adjustment - Modern passing games produce higher EPA - aDOT varies by scheme
-
Overweighting single-game EPA - High variance game-to-game - Use rolling averages or season totals
Pandas Quick Reference
import pandas as pd
import numpy as np
# Calculate CPOE for multiple QBs
df['cpoe'] = df['actual_comp_pct'] - df['expected_comp_pct']
# EPA success rate
df['success'] = df['epa'] > 0
success_rate = df.groupby('qb')['success'].mean() * 100
# Split by pressure
clean = df[~df['under_pressure']]
pressured = df[df['under_pressure']]
# Depth zone analysis
df['depth_zone'] = pd.cut(df['air_yards'],
bins=[-10, 0, 10, 20, 100],
labels=['Behind', 'Short', 'Medium', 'Deep'])
zone_stats = df.groupby('depth_zone').agg({
'completed': 'mean',
'yards': 'mean',
'epa': 'mean'
})
# Rolling EPA (3-game average)
df['rolling_epa'] = df.groupby('qb')['epa_per_db'].rolling(3).mean().values
# Composite ranking
df['cpoe_rank'] = df['cpoe'].rank(ascending=False)
df['epa_rank'] = df['epa_per_db'].rank(ascending=False)
df['composite_rank'] = (df['cpoe_rank'] + df['epa_rank']) / 2
Chapter Summary
Advanced passing metrics provide deeper insight than traditional statistics:
| Traditional | Advanced Replacement | Improvement |
|---|---|---|
| Completion % | CPOE | Accounts for difficulty |
| Yards | EPA | Values situation context |
| TD/INT | EPA (captures both) | Single value metric |
| Passer Rating | Composite (EPA + CPOE) | More predictive |
Key Insight: The best evaluation combines multiple advanced metrics with situational splits (pressure, depth, game situation) for complete quarterback assessment.