Traditional passing statistics like completion percentage, yards per attempt, and passer rating provide useful summaries of quarterback performance, but they fail to capture crucial aspects of modern passing games. This chapter introduces advanced...
In This Chapter
- Learning Objectives
- 7.1 Introduction: Beyond Passer Rating
- 7.2 Expected Points Added (EPA) for Passing
- 7.3 Completion Probability Models
- 7.4 Completion Percentage Over Expected (CPOE)
- 7.5 Air Yards Analysis
- 7.6 Adjusted Passing Metrics
- 7.7 Comprehensive Quarterback Evaluation
- 7.8 Practical Applications
- 7.9 Summary
- Exercises
- Further Reading
Chapter 7: Advanced Passing Metrics
Learning Objectives
By the end of this chapter, you will be able to:
- Understand the limitations of traditional passing statistics
- Calculate and interpret Expected Points Added (EPA) for passing plays
- Implement completion probability models
- Calculate Completion Percentage Over Expected (CPOE)
- Analyze air yards, yards after catch, and depth of target
- Build adjusted passing metrics that account for context
- Create comprehensive quarterback evaluation systems
7.1 Introduction: Beyond Passer Rating
Traditional passing statistics like completion percentage, yards per attempt, and passer rating provide useful summaries of quarterback performance, but they fail to capture crucial aspects of modern passing games. This chapter introduces advanced metrics that address these limitations.
The Problem with Traditional Metrics
Consider two passes: 1. A 3-yard screen pass on 3rd and 15 that gains 5 yards (incomplete drive, punt) 2. A 15-yard pass on 3rd and 10 that converts a crucial first down
Traditional statistics treat these plays similarly—both are completions, both contribute to completion percentage, and both yards count equally toward yards per attempt. Yet their impact on the game is vastly different.
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple, Optional
# Example: Same traditional stats, different value
play_1 = {
'down': 3, 'distance': 15, 'yards_gained': 5,
'result': 'complete', 'first_down': False, 'ep_before': 0.8, 'ep_after': -0.5
}
play_2 = {
'down': 3, 'distance': 10, 'yards_gained': 15,
'result': 'complete', 'first_down': True, 'ep_before': 1.2, 'ep_after': 2.8
}
# Traditional stats see these similarly
print("Traditional View:")
print(f" Play 1: Complete, 5 yards")
print(f" Play 2: Complete, 15 yards")
# Advanced metrics reveal the difference
print("\nAdvanced View (EPA):")
print(f" Play 1 EPA: {play_1['ep_after'] - play_1['ep_before']:.1f}")
print(f" Play 2 EPA: {play_2['ep_after'] - play_2['ep_before']:.1f}")
What This Chapter Covers
- Expected Points Added (EPA): Measuring the value of each pass
- Completion Probability Models: What should a QB complete?
- CPOE: Completion Percentage Over Expected
- Air Yards Analysis: Understanding target depth
- Adjusted Metrics: Context-aware quarterback evaluation
- Composite Rankings: Building complete evaluation systems
7.2 Expected Points Added (EPA) for Passing
Expected Points Added measures the change in expected points resulting from a play. For passing plays, EPA captures not just yards gained but the game situation context.
Understanding Expected Points
Expected points (EP) is the average number of points a team can expect to score given their current field position, down, and distance. The EP value changes with each play.
class ExpectedPointsModel:
"""
Simplified Expected Points model for passing analysis.
In production, EP models are trained on historical play-by-play data.
This implementation provides approximations for educational purposes.
"""
def __init__(self):
"""Initialize with baseline EP values by field position."""
# Approximate EP by yard line (own territory = negative, opp = positive)
# These values are simplified approximations
self.baseline_ep = self._create_baseline_ep()
def _create_baseline_ep(self) -> Dict[int, float]:
"""Create baseline EP values by yard line."""
ep_values = {}
for yard_line in range(1, 100):
# Own 1-yard line to opponent's goal line
# Simplified model: roughly linear with adjustments
if yard_line <= 10:
ep_values[yard_line] = -0.5 + (yard_line * 0.05)
elif yard_line <= 50:
ep_values[yard_line] = 0 + ((yard_line - 10) * 0.06)
elif yard_line <= 80:
ep_values[yard_line] = 2.4 + ((yard_line - 50) * 0.08)
else:
ep_values[yard_line] = 4.8 + ((yard_line - 80) * 0.12)
return ep_values
def get_ep(self, yard_line: int, down: int, distance: int) -> float:
"""
Get expected points for a given situation.
Parameters:
-----------
yard_line : int
Field position (1-99, where 50 is midfield)
down : int
Current down (1-4)
distance : int
Yards to first down
Returns:
--------
float : Expected points value
"""
# Get baseline EP from field position
base_ep = self.baseline_ep.get(yard_line, 0)
# Adjust for down and distance
down_adjustment = {
1: 0.2, # 1st down is favorable
2: 0.0, # 2nd down is neutral
3: -0.3, # 3rd down has pressure
4: -0.8 # 4th down is unfavorable
}
distance_adjustment = min(distance, 20) * -0.03 # Longer distance = lower EP
return base_ep + down_adjustment.get(down, 0) + distance_adjustment
def calculate_epa(self, play_data: Dict) -> float:
"""
Calculate EPA for a single play.
Parameters:
-----------
play_data : dict
Dictionary containing:
- yard_line_before: Starting field position
- yard_line_after: Ending field position
- down_before: Down at start
- down_after: Down at end (or None if turnover/score)
- distance_before: Distance at start
- distance_after: Distance at end
- result: 'complete', 'incomplete', 'interception', 'touchdown', 'sack'
Returns:
--------
float : Expected Points Added
"""
# Handle special cases
if play_data['result'] == 'touchdown':
ep_after = 7.0 # TD value
elif play_data['result'] == 'interception':
# Interception gives ball to opponent
opp_yard_line = 100 - play_data.get('yard_line_after', 75)
ep_after = -self.get_ep(opp_yard_line, 1, 10)
elif play_data['result'] == 'incomplete':
# Incomplete pass, next down same yard line
ep_after = self.get_ep(
play_data['yard_line_before'],
play_data['down_before'] + 1,
play_data['distance_before']
)
# If 4th down incomplete, turnover on downs
if play_data['down_before'] == 4:
opp_yard_line = 100 - play_data['yard_line_before']
ep_after = -self.get_ep(opp_yard_line, 1, 10)
else:
# Complete or sack
ep_after = self.get_ep(
play_data['yard_line_after'],
play_data['down_after'],
play_data['distance_after']
)
ep_before = self.get_ep(
play_data['yard_line_before'],
play_data['down_before'],
play_data['distance_before']
)
return round(ep_after - ep_before, 2)
# Demonstrate EPA calculation
ep_model = ExpectedPointsModel()
# Example plays
plays = [
{
'description': '1st and 10 at own 25, 8-yard completion',
'yard_line_before': 25, 'yard_line_after': 33,
'down_before': 1, 'down_after': 2,
'distance_before': 10, 'distance_after': 2,
'result': 'complete'
},
{
'description': '3rd and 7 at opponent 35, 12-yard TD pass',
'yard_line_before': 65, 'yard_line_after': 100,
'down_before': 3, 'down_after': None,
'distance_before': 7, 'distance_after': 0,
'result': 'touchdown'
},
{
'description': '2nd and 8 at own 40, interception',
'yard_line_before': 40, 'yard_line_after': 45,
'down_before': 2, 'down_after': None,
'distance_before': 8, 'distance_after': 0,
'result': 'interception'
}
]
print("EPA Examples:")
print("-" * 60)
for play in plays:
epa = ep_model.calculate_epa(play)
print(f"{play['description']}")
print(f" EPA: {epa:+.2f}")
print()
EPA Aggregation for Quarterbacks
def calculate_qb_epa_metrics(plays: List[Dict]) -> Dict:
"""
Calculate aggregate EPA metrics for a quarterback.
Parameters:
-----------
plays : list
List of play dictionaries with EPA values
Returns:
--------
dict : Aggregate EPA metrics
"""
passing_plays = [p for p in plays if p.get('play_type') == 'pass']
if not passing_plays:
return {}
total_epa = sum(p.get('epa', 0) for p in passing_plays)
dropbacks = len(passing_plays)
# Success rate (positive EPA plays)
successful_plays = [p for p in passing_plays if p.get('epa', 0) > 0]
success_rate = len(successful_plays) / dropbacks * 100
# EPA by situation
early_down_plays = [p for p in passing_plays if p.get('down', 0) in [1, 2]]
late_down_plays = [p for p in passing_plays if p.get('down', 0) in [3, 4]]
early_down_epa = sum(p.get('epa', 0) for p in early_down_plays) / len(early_down_plays) if early_down_plays else 0
late_down_epa = sum(p.get('epa', 0) for p in late_down_plays) / len(late_down_plays) if late_down_plays else 0
return {
'total_epa': round(total_epa, 2),
'dropbacks': dropbacks,
'epa_per_dropback': round(total_epa / dropbacks, 3),
'success_rate': round(success_rate, 1),
'early_down_epa': round(early_down_epa, 3),
'late_down_epa': round(late_down_epa, 3)
}
7.3 Completion Probability Models
Not all completions are created equal. A 5-yard checkdown should be completed more often than a 40-yard deep ball. Completion probability models estimate the expected completion rate for each pass based on various factors.
Factors Affecting Completion Probability
- Air Yards (Depth of Target): Deeper passes are harder to complete
- Receiver Separation: More separation = higher completion probability
- Pressure: Passes under pressure are harder to complete
- Down and Distance: Affects defensive coverage
- Score Differential: Garbage time vs. close games
- Weather Conditions: Wind, rain affect passing
class CompletionProbabilityModel:
"""
Model for predicting completion probability.
This simplified model uses air yards and pressure as primary factors.
Production models include tracking data for separation, etc.
"""
def __init__(self):
"""Initialize model coefficients."""
# Logistic regression coefficients (simplified)
self.intercept = 2.0
self.air_yards_coef = -0.08
self.pressure_coef = -0.6
self.third_down_coef = -0.2
def predict_completion_probability(self, air_yards: float,
under_pressure: bool = False,
third_down: bool = False) -> float:
"""
Predict completion probability for a pass.
Parameters:
-----------
air_yards : float
Depth of target in yards
under_pressure : bool
Whether QB was under pressure
third_down : bool
Whether it's third down
Returns:
--------
float : Predicted completion probability (0-1)
"""
# Calculate log-odds
log_odds = self.intercept
log_odds += self.air_yards_coef * air_yards
log_odds += self.pressure_coef * (1 if under_pressure else 0)
log_odds += self.third_down_coef * (1 if third_down else 0)
# Convert to probability using sigmoid function
probability = 1 / (1 + np.exp(-log_odds))
return round(probability, 3)
def get_expected_completions(self, passes: List[Dict]) -> float:
"""
Calculate total expected completions for a set of passes.
Parameters:
-----------
passes : list
List of pass dictionaries
Returns:
--------
float : Sum of completion probabilities
"""
total = 0
for p in passes:
prob = self.predict_completion_probability(
p.get('air_yards', 5),
p.get('under_pressure', False),
p.get('third_down', False)
)
total += prob
return round(total, 1)
# Demonstrate completion probability
cp_model = CompletionProbabilityModel()
print("Completion Probability Examples:")
print("-" * 60)
scenarios = [
{'air_yards': 3, 'under_pressure': False, 'third_down': False,
'desc': 'Short pass, clean pocket'},
{'air_yards': 15, 'under_pressure': False, 'third_down': False,
'desc': 'Intermediate pass, clean pocket'},
{'air_yards': 30, 'under_pressure': False, 'third_down': False,
'desc': 'Deep pass, clean pocket'},
{'air_yards': 10, 'under_pressure': True, 'third_down': False,
'desc': 'Intermediate pass, under pressure'},
{'air_yards': 15, 'under_pressure': False, 'third_down': True,
'desc': 'Third down intermediate pass'},
{'air_yards': 25, 'under_pressure': True, 'third_down': True,
'desc': 'Deep third down pass under pressure'},
]
for s in scenarios:
prob = cp_model.predict_completion_probability(
s['air_yards'], s['under_pressure'], s['third_down']
)
print(f"{s['desc']}")
print(f" Air Yards: {s['air_yards']}, Pressure: {s['under_pressure']}")
print(f" Completion Probability: {prob:.1%}")
print()
7.4 Completion Percentage Over Expected (CPOE)
CPOE measures how much better (or worse) a quarterback's actual completion percentage is compared to what would be expected given the difficulty of his throws.
class CPOECalculator:
"""Calculate Completion Percentage Over Expected."""
def __init__(self):
"""Initialize with completion probability model."""
self.cp_model = CompletionProbabilityModel()
def calculate_cpoe(self, passes: List[Dict]) -> Dict:
"""
Calculate CPOE for a set of passes.
Parameters:
-----------
passes : list
List of pass dictionaries with:
- completed: bool
- air_yards: float
- under_pressure: bool (optional)
- third_down: bool (optional)
Returns:
--------
dict : CPOE metrics including total, per-pass, and breakdown
"""
if not passes:
return {}
# Calculate expected and actual completions
expected_completions = 0
actual_completions = 0
pass_details = []
for p in passes:
exp_prob = self.cp_model.predict_completion_probability(
p.get('air_yards', 5),
p.get('under_pressure', False),
p.get('third_down', False)
)
expected_completions += exp_prob
actual = 1 if p.get('completed', False) else 0
actual_completions += actual
pass_details.append({
'air_yards': p.get('air_yards', 5),
'expected': exp_prob,
'actual': actual,
'cpoe_contribution': actual - exp_prob
})
total_passes = len(passes)
expected_comp_pct = expected_completions / total_passes * 100
actual_comp_pct = actual_completions / total_passes * 100
cpoe = actual_comp_pct - expected_comp_pct
return {
'total_passes': total_passes,
'actual_completions': actual_completions,
'expected_completions': round(expected_completions, 1),
'actual_comp_pct': round(actual_comp_pct, 1),
'expected_comp_pct': round(expected_comp_pct, 1),
'cpoe': round(cpoe, 1),
'cpoe_per_pass': round(cpoe / 100, 3), # As a rate per pass
'pass_details': pass_details
}
def compare_quarterbacks(self, qb_passes: Dict[str, List[Dict]]) -> pd.DataFrame:
"""
Compare CPOE for multiple quarterbacks.
Parameters:
-----------
qb_passes : dict
Dictionary mapping QB names to their passes
Returns:
--------
pd.DataFrame : Comparison with rankings
"""
results = []
for qb_name, passes in qb_passes.items():
cpoe_stats = self.calculate_cpoe(passes)
# Calculate average air yards
avg_air_yards = np.mean([p.get('air_yards', 0) for p in passes])
results.append({
'quarterback': qb_name,
'attempts': cpoe_stats['total_passes'],
'completions': cpoe_stats['actual_completions'],
'comp_pct': cpoe_stats['actual_comp_pct'],
'expected_comp_pct': cpoe_stats['expected_comp_pct'],
'cpoe': cpoe_stats['cpoe'],
'avg_air_yards': round(avg_air_yards, 1)
})
df = pd.DataFrame(results)
df['cpoe_rank'] = df['cpoe'].rank(ascending=False).astype(int)
df['adot_rank'] = df['avg_air_yards'].rank(ascending=False).astype(int)
return df.sort_values('cpoe', ascending=False)
# Demonstrate CPOE calculation
cpoe_calc = CPOECalculator()
# Generate sample passes for a QB
sample_passes = []
np.random.seed(42)
for _ in range(50):
air_yards = np.random.choice([3, 5, 8, 12, 15, 20, 30], p=[0.2, 0.2, 0.2, 0.15, 0.1, 0.1, 0.05])
# Completion probability based on air yards
base_prob = 0.85 - (air_yards * 0.015)
completed = np.random.random() < (base_prob + 0.05) # Slightly above average QB
sample_passes.append({
'air_yards': air_yards,
'completed': completed,
'under_pressure': np.random.random() < 0.25,
'third_down': np.random.random() < 0.35
})
cpoe_result = cpoe_calc.calculate_cpoe(sample_passes)
print("\nCPOE Analysis:")
print("-" * 60)
print(f"Total Passes: {cpoe_result['total_passes']}")
print(f"Actual Completions: {cpoe_result['actual_completions']}")
print(f"Expected Completions: {cpoe_result['expected_completions']}")
print(f"Actual Comp %: {cpoe_result['actual_comp_pct']:.1f}%")
print(f"Expected Comp %: {cpoe_result['expected_comp_pct']:.1f}%")
print(f"CPOE: {cpoe_result['cpoe']:+.1f}%")
7.5 Air Yards Analysis
Air yards measure the distance a pass travels in the air before reaching the receiver. This metric reveals important information about a quarterback's passing style and aggressiveness.
Key Air Yards Metrics
class AirYardsAnalyzer:
"""Analyze air yards and related metrics."""
def __init__(self):
"""Initialize the analyzer."""
pass
def calculate_air_yards_metrics(self, passes: List[Dict]) -> Dict:
"""
Calculate comprehensive air yards metrics.
Parameters:
-----------
passes : list
List of pass dictionaries with air_yards, completed, yards_gained
Returns:
--------
dict : Air yards metrics
"""
if not passes:
return {}
completed_passes = [p for p in passes if p.get('completed', False)]
# Basic metrics
total_air_yards = sum(p.get('air_yards', 0) for p in passes)
completed_air_yards = sum(p.get('air_yards', 0) for p in completed_passes)
# Intended air yards per attempt
iay_pa = total_air_yards / len(passes)
# Completed air yards per attempt
cay_pa = completed_air_yards / len(passes)
# Average depth of target (aDOT)
adot = total_air_yards / len(passes)
# Yards after catch analysis
total_yac = sum(p.get('yards_gained', 0) - p.get('air_yards', 0)
for p in completed_passes if p.get('yards_gained', 0) > p.get('air_yards', 0))
avg_yac = total_yac / len(completed_passes) if completed_passes else 0
# Air yards share of total yards
total_yards = sum(p.get('yards_gained', 0) for p in completed_passes)
air_yards_share = completed_air_yards / total_yards * 100 if total_yards > 0 else 0
# Pass depth distribution
short_passes = [p for p in passes if p.get('air_yards', 0) < 10]
medium_passes = [p for p in passes if 10 <= p.get('air_yards', 0) < 20]
deep_passes = [p for p in passes if p.get('air_yards', 0) >= 20]
return {
'total_passes': len(passes),
'completed_passes': len(completed_passes),
'total_air_yards': round(total_air_yards, 1),
'completed_air_yards': round(completed_air_yards, 1),
'iay_pa': round(iay_pa, 2), # Intended Air Yards per Attempt
'cay_pa': round(cay_pa, 2), # Completed Air Yards per Attempt
'adot': round(adot, 2), # Average Depth of Target
'avg_yac': round(avg_yac, 2),
'air_yards_share': round(air_yards_share, 1),
'short_pass_pct': round(len(short_passes) / len(passes) * 100, 1),
'medium_pass_pct': round(len(medium_passes) / len(passes) * 100, 1),
'deep_pass_pct': round(len(deep_passes) / len(passes) * 100, 1)
}
def analyze_by_depth(self, passes: List[Dict]) -> pd.DataFrame:
"""
Analyze performance by pass depth zones.
Parameters:
-----------
passes : list
Returns:
--------
pd.DataFrame : Performance by depth zone
"""
zones = {
'Behind LOS': (-5, 0),
'Short (0-9)': (0, 10),
'Medium (10-19)': (10, 20),
'Deep (20+)': (20, 100)
}
results = []
for zone_name, (min_yards, max_yards) in zones.items():
zone_passes = [p for p in passes
if min_yards <= p.get('air_yards', 0) < max_yards]
if zone_passes:
completions = sum(1 for p in zone_passes if p.get('completed', False))
comp_pct = completions / len(zone_passes) * 100
avg_yards = np.mean([p.get('yards_gained', 0)
for p in zone_passes if p.get('completed', False)]) if completions > 0 else 0
results.append({
'depth_zone': zone_name,
'attempts': len(zone_passes),
'completions': completions,
'comp_pct': round(comp_pct, 1),
'pct_of_attempts': round(len(zone_passes) / len(passes) * 100, 1),
'avg_yards_gained': round(avg_yards, 1)
})
return pd.DataFrame(results)
# Demonstrate air yards analysis
ay_analyzer = AirYardsAnalyzer()
# Generate sample passes with air yards and yards gained
sample_passes_with_yac = []
np.random.seed(42)
for _ in range(100):
air_yards = np.random.choice([-2, 0, 3, 5, 8, 12, 15, 20, 30, 40],
p=[0.05, 0.1, 0.15, 0.2, 0.15, 0.15, 0.1, 0.05, 0.03, 0.02])
completed = np.random.random() < (0.85 - air_yards * 0.012)
if completed:
yac = max(0, int(np.random.exponential(5)))
yards_gained = air_yards + yac
else:
yards_gained = 0
sample_passes_with_yac.append({
'air_yards': air_yards,
'completed': completed,
'yards_gained': yards_gained
})
ay_metrics = ay_analyzer.calculate_air_yards_metrics(sample_passes_with_yac)
depth_analysis = ay_analyzer.analyze_by_depth(sample_passes_with_yac)
print("\nAir Yards Analysis:")
print("-" * 60)
for k, v in ay_metrics.items():
if k not in ['total_passes', 'completed_passes']:
print(f" {k}: {v}")
print("\nPerformance by Depth Zone:")
print(depth_analysis.to_string(index=False))
7.6 Adjusted Passing Metrics
Adjusted metrics account for context that traditional statistics ignore, providing fairer quarterback comparisons.
Pressure-Adjusted Stats
class AdjustedPassingMetrics:
"""Calculate context-adjusted passing metrics."""
def __init__(self):
"""Initialize calculators."""
self.cp_model = CompletionProbabilityModel()
self.ep_model = ExpectedPointsModel()
def calculate_pressure_adjusted_stats(self, passes: List[Dict]) -> Dict:
"""
Calculate stats adjusted for pocket pressure.
Parameters:
-----------
passes : list
List of passes with pressure information
Returns:
--------
dict : Pressure-adjusted metrics
"""
clean_pocket = [p for p in passes if not p.get('under_pressure', False)]
pressured = [p for p in passes if p.get('under_pressure', False)]
def calc_stats(pass_list):
if not pass_list:
return {'comp_pct': 0, 'avg_yards': 0, 'td_rate': 0, 'int_rate': 0}
completions = sum(1 for p in pass_list if p.get('completed'))
attempts = len(pass_list)
yards = sum(p.get('yards_gained', 0) for p in pass_list if p.get('completed'))
tds = sum(1 for p in pass_list if p.get('touchdown'))
ints = sum(1 for p in pass_list if p.get('interception'))
return {
'attempts': attempts,
'comp_pct': round(completions / attempts * 100, 1) if attempts else 0,
'avg_yards': round(yards / completions, 1) if completions else 0,
'td_rate': round(tds / attempts * 100, 2) if attempts else 0,
'int_rate': round(ints / attempts * 100, 2) if attempts else 0
}
clean_stats = calc_stats(clean_pocket)
pressure_stats = calc_stats(pressured)
# Calculate pressure rate
pressure_rate = len(pressured) / len(passes) * 100 if passes else 0
# Adjusted completion percentage (league avg pressure rates)
league_avg_pressure_rate = 25 # Typical pressure rate
adjusted_comp_pct = (
clean_stats['comp_pct'] * (100 - league_avg_pressure_rate) / 100 +
pressure_stats['comp_pct'] * league_avg_pressure_rate / 100
)
return {
'clean_pocket': clean_stats,
'under_pressure': pressure_stats,
'pressure_rate': round(pressure_rate, 1),
'adjusted_comp_pct': round(adjusted_comp_pct, 1),
'pressure_comp_diff': round(clean_stats['comp_pct'] - pressure_stats['comp_pct'], 1)
}
def calculate_opponent_adjusted_stats(self, passes: List[Dict],
opponent_def_rank: Dict[str, int]) -> Dict:
"""
Calculate stats adjusted for opponent defensive strength.
Parameters:
-----------
passes : list
List of passes with opponent info
opponent_def_rank : dict
Mapping of opponent to defensive ranking (1-130)
Returns:
--------
dict : Opponent-adjusted metrics
"""
# Group passes by opponent strength tier
tiers = {
'elite_defense': [], # Top 25
'good_defense': [], # 26-50
'average_defense': [], # 51-80
'poor_defense': [] # 81+
}
for p in passes:
opp = p.get('opponent', 'Unknown')
rank = opponent_def_rank.get(opp, 65) # Default to average
if rank <= 25:
tiers['elite_defense'].append(p)
elif rank <= 50:
tiers['good_defense'].append(p)
elif rank <= 80:
tiers['average_defense'].append(p)
else:
tiers['poor_defense'].append(p)
# Calculate stats for each tier
tier_stats = {}
weights = {'elite_defense': 1.2, 'good_defense': 1.1,
'average_defense': 1.0, 'poor_defense': 0.9}
weighted_comp_pct = 0
total_weight = 0
for tier, passes_in_tier in tiers.items():
if passes_in_tier:
completions = sum(1 for p in passes_in_tier if p.get('completed'))
comp_pct = completions / len(passes_in_tier) * 100
tier_stats[tier] = {
'attempts': len(passes_in_tier),
'comp_pct': round(comp_pct, 1)
}
weighted_comp_pct += comp_pct * weights[tier] * len(passes_in_tier)
total_weight += weights[tier] * len(passes_in_tier)
adjusted_comp_pct = weighted_comp_pct / total_weight if total_weight > 0 else 0
return {
'tier_breakdown': tier_stats,
'opponent_adjusted_comp_pct': round(adjusted_comp_pct, 1)
}
# Demonstrate adjusted metrics
adj_calc = AdjustedPassingMetrics()
# Generate sample passes with pressure data
sample_adjusted_passes = []
np.random.seed(42)
for _ in range(150):
under_pressure = np.random.random() < 0.28
air_yards = np.random.choice([3, 5, 8, 12, 15, 20, 30], p=[0.2, 0.2, 0.2, 0.15, 0.1, 0.1, 0.05])
# Completion probability affected by pressure
base_prob = 0.75 - air_yards * 0.012
if under_pressure:
base_prob -= 0.15
completed = np.random.random() < base_prob
sample_adjusted_passes.append({
'air_yards': air_yards,
'completed': completed,
'yards_gained': air_yards + np.random.randint(0, 8) if completed else 0,
'under_pressure': under_pressure,
'touchdown': completed and np.random.random() < 0.05,
'interception': not completed and np.random.random() < 0.03
})
pressure_adjusted = adj_calc.calculate_pressure_adjusted_stats(sample_adjusted_passes)
print("\nPressure-Adjusted Statistics:")
print("-" * 60)
print(f"Pressure Rate: {pressure_adjusted['pressure_rate']:.1f}%")
print(f"\nClean Pocket:")
for k, v in pressure_adjusted['clean_pocket'].items():
print(f" {k}: {v}")
print(f"\nUnder Pressure:")
for k, v in pressure_adjusted['under_pressure'].items():
print(f" {k}: {v}")
print(f"\nAdjusted Comp %: {pressure_adjusted['adjusted_comp_pct']:.1f}%")
print(f"Pressure Completion Drop: {pressure_adjusted['pressure_comp_diff']:.1f}%")
7.7 Comprehensive Quarterback Evaluation
Building a complete quarterback evaluation system requires combining multiple advanced metrics.
class ComprehensiveQBEvaluator:
"""
Comprehensive quarterback evaluation system.
Combines traditional and advanced metrics for complete QB assessment.
"""
def __init__(self):
"""Initialize component calculators."""
self.cpoe_calc = CPOECalculator()
self.ay_analyzer = AirYardsAnalyzer()
self.adj_calc = AdjustedPassingMetrics()
def evaluate_quarterback(self, qb_name: str, passes: List[Dict],
games: int) -> Dict:
"""
Generate comprehensive QB evaluation.
Parameters:
-----------
qb_name : str
Quarterback name
passes : list
List of all pass attempts with full data
games : int
Number of games played
Returns:
--------
dict : Complete evaluation
"""
# Traditional stats
attempts = len(passes)
completions = sum(1 for p in passes if p.get('completed'))
yards = sum(p.get('yards_gained', 0) for p in passes if p.get('completed'))
tds = sum(1 for p in passes if p.get('touchdown'))
ints = sum(1 for p in passes if p.get('interception'))
traditional = {
'attempts': attempts,
'completions': completions,
'yards': yards,
'touchdowns': tds,
'interceptions': ints,
'comp_pct': round(completions / attempts * 100, 1) if attempts else 0,
'ypa': round(yards / attempts, 2) if attempts else 0,
'td_pct': round(tds / attempts * 100, 1) if attempts else 0,
'int_pct': round(ints / attempts * 100, 1) if attempts else 0,
'ypg': round(yards / games, 1) if games else 0
}
# CPOE
cpoe_stats = self.cpoe_calc.calculate_cpoe(passes)
# Air yards
ay_stats = self.ay_analyzer.calculate_air_yards_metrics(passes)
# Pressure-adjusted
pressure_stats = self.adj_calc.calculate_pressure_adjusted_stats(passes)
# EPA (simplified calculation)
avg_epa = np.mean([p.get('epa', 0) for p in passes if 'epa' in p]) if any('epa' in p for p in passes) else None
# Composite score (weighted combination)
def normalize(value, min_val, max_val, higher_better=True):
"""Normalize value to 0-100 scale."""
if value is None:
return 50
normalized = (value - min_val) / (max_val - min_val) * 100
normalized = max(0, min(100, normalized))
return normalized if higher_better else 100 - normalized
# Calculate composite (simplified weights)
composite = (
normalize(cpoe_stats.get('cpoe', 0), -10, 10) * 0.25 +
normalize(ay_stats.get('adot', 7), 5, 12) * 0.15 +
normalize(pressure_stats.get('clean_pocket', {}).get('comp_pct', 65), 55, 75) * 0.20 +
normalize(traditional['ypa'], 6, 10) * 0.20 +
normalize(traditional['td_pct'], 3, 8) * 0.10 +
normalize(traditional['int_pct'], 4, 1, higher_better=False) * 0.10
)
return {
'quarterback': qb_name,
'games': games,
'traditional': traditional,
'cpoe': cpoe_stats.get('cpoe', 0),
'expected_comp_pct': cpoe_stats.get('expected_comp_pct', 0),
'adot': ay_stats.get('adot', 0),
'air_yards_share': ay_stats.get('air_yards_share', 0),
'avg_yac': ay_stats.get('avg_yac', 0),
'pressure_rate': pressure_stats.get('pressure_rate', 0),
'clean_pocket_comp_pct': pressure_stats.get('clean_pocket', {}).get('comp_pct', 0),
'pressured_comp_pct': pressure_stats.get('under_pressure', {}).get('comp_pct', 0),
'avg_epa': avg_epa,
'composite_score': round(composite, 1)
}
def compare_quarterbacks(self, qb_data: Dict[str, Tuple[List[Dict], int]]) -> pd.DataFrame:
"""
Compare multiple quarterbacks comprehensively.
Parameters:
-----------
qb_data : dict
Mapping of QB name to (passes, games) tuple
Returns:
--------
pd.DataFrame : Comparison results
"""
results = []
for qb_name, (passes, games) in qb_data.items():
eval_result = self.evaluate_quarterback(qb_name, passes, games)
results.append({
'quarterback': qb_name,
'games': games,
'comp_pct': eval_result['traditional']['comp_pct'],
'ypa': eval_result['traditional']['ypa'],
'cpoe': eval_result['cpoe'],
'adot': eval_result['adot'],
'clean_pocket_pct': eval_result['clean_pocket_comp_pct'],
'pressure_rate': eval_result['pressure_rate'],
'composite': eval_result['composite_score']
})
df = pd.DataFrame(results)
# Add rankings
df['composite_rank'] = df['composite'].rank(ascending=False).astype(int)
df['cpoe_rank'] = df['cpoe'].rank(ascending=False).astype(int)
df['ypa_rank'] = df['ypa'].rank(ascending=False).astype(int)
return df.sort_values('composite_rank')
def generate_report(self, qb_name: str, evaluation: Dict) -> str:
"""
Generate formatted evaluation report.
Parameters:
-----------
qb_name : str
evaluation : dict
Output from evaluate_quarterback
Returns:
--------
str : Formatted report
"""
trad = evaluation['traditional']
report = f"""
╔══════════════════════════════════════════════════════════════════════════╗
║ COMPREHENSIVE QUARTERBACK EVALUATION ║
║ {qb_name:^30} ║
╠══════════════════════════════════════════════════════════════════════════╣
║ TRADITIONAL STATISTICS ║
╠══════════════════════════════════════════════════════════════════════════╣
║ Attempts: {trad['attempts']:>5} Completions: {trad['completions']:>5} Comp%: {trad['comp_pct']:>5.1f}% ║
║ Yards: {trad['yards']:>6} YPA: {trad['ypa']:>5.2f} YPG: {trad['ypg']:>6.1f} ║
║ TDs: {trad['touchdowns']:>5} INTs: {trad['interceptions']:>5} TD%: {trad['td_pct']:>5.1f}% ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ADVANCED METRICS ║
╠══════════════════════════════════════════════════════════════════════════╣
║ CPOE: {evaluation['cpoe']:>+5.1f}% ║
║ Expected Comp%: {evaluation['expected_comp_pct']:>5.1f}% Actual: {trad['comp_pct']:>5.1f}% ║
║ aDOT: {evaluation['adot']:>5.2f} Air Yards Share: {evaluation['air_yards_share']:>5.1f}% ║
║ Avg YAC: {evaluation['avg_yac']:>5.2f} ║
╠══════════════════════════════════════════════════════════════════════════╣
║ PRESSURE PERFORMANCE ║
╠══════════════════════════════════════════════════════════════════════════╣
║ Pressure Rate: {evaluation['pressure_rate']:>5.1f}% ║
║ Clean Pocket Comp%: {evaluation['clean_pocket_comp_pct']:>5.1f}% ║
║ Under Pressure Comp%: {evaluation['pressured_comp_pct']:>5.1f}% ║
╠══════════════════════════════════════════════════════════════════════════╣
║ COMPOSITE SCORE ║
╠══════════════════════════════════════════════════════════════════════════╣
║ {evaluation['composite_score']:>5.1f} / 100 ║
╚══════════════════════════════════════════════════════════════════════════╝
"""
return report
# Demonstrate comprehensive evaluation
evaluator = ComprehensiveQBEvaluator()
# Generate sample passes for demonstration
def generate_qb_passes(count: int, skill_level: float) -> List[Dict]:
"""Generate sample passes with given skill modifier."""
passes = []
for _ in range(count):
under_pressure = np.random.random() < 0.28
air_yards = np.random.choice([-2, 0, 3, 5, 8, 12, 15, 20, 30],
p=[0.03, 0.07, 0.15, 0.2, 0.2, 0.15, 0.1, 0.06, 0.04])
base_prob = 0.72 + skill_level * 0.08 - air_yards * 0.01
if under_pressure:
base_prob -= 0.12
completed = np.random.random() < base_prob
yac = np.random.randint(0, 10) if completed else 0
passes.append({
'air_yards': air_yards,
'completed': completed,
'yards_gained': air_yards + yac if completed else 0,
'under_pressure': under_pressure,
'touchdown': completed and air_yards >= 15 and np.random.random() < 0.15,
'interception': not completed and np.random.random() < 0.04,
'third_down': np.random.random() < 0.35
})
return passes
np.random.seed(42)
qb_comparison_data = {
'Elite QB': (generate_qb_passes(400, 0.8), 13),
'Good QB': (generate_qb_passes(380, 0.5), 12),
'Average QB': (generate_qb_passes(420, 0.2), 14),
'Below Avg QB': (generate_qb_passes(350, -0.1), 11)
}
comparison = evaluator.compare_quarterbacks(qb_comparison_data)
print("\nQuarterback Comparison:")
print("-" * 80)
print(comparison[['quarterback', 'comp_pct', 'ypa', 'cpoe', 'adot',
'composite', 'composite_rank']].to_string(index=False))
# Generate detailed report for top QB
top_qb_name = comparison.iloc[0]['quarterback']
top_qb_passes, top_qb_games = qb_comparison_data[top_qb_name]
detailed_eval = evaluator.evaluate_quarterback(top_qb_name, top_qb_passes, top_qb_games)
report = evaluator.generate_report(top_qb_name, detailed_eval)
print(report)
7.8 Practical Applications
Scouting and Draft Analysis
def create_draft_profile(qb_name: str, college_passes: List[Dict],
games: int, evaluator: ComprehensiveQBEvaluator) -> Dict:
"""
Create draft scouting profile for a quarterback.
Parameters:
-----------
qb_name : str
college_passes : list
games : int
evaluator : ComprehensiveQBEvaluator
Returns:
--------
dict : Draft profile with projections
"""
# Get evaluation
eval_result = evaluator.evaluate_quarterback(qb_name, college_passes, games)
# Identify strengths and weaknesses
strengths = []
weaknesses = []
if eval_result['cpoe'] > 3:
strengths.append("Elite accuracy above expectation")
elif eval_result['cpoe'] < -2:
weaknesses.append("Below expected accuracy")
if eval_result['adot'] > 9:
strengths.append("Aggressive downfield passer")
elif eval_result['adot'] < 7:
weaknesses.append("Relies on short/safe passes")
trad = eval_result['traditional']
if trad['comp_pct'] - eval_result['pressured_comp_pct'] < 10:
strengths.append("Maintains composure under pressure")
elif trad['comp_pct'] - eval_result['pressured_comp_pct'] > 20:
weaknesses.append("Struggles under pressure")
if eval_result['avg_yac'] > 5:
strengths.append("Receivers gain YAC (scheme fit or ball placement)")
# Draft grade based on composite
composite = eval_result['composite_score']
if composite >= 75:
grade = "First Round"
elif composite >= 65:
grade = "Second Round"
elif composite >= 55:
grade = "Day 2"
elif composite >= 45:
grade = "Day 3"
else:
grade = "Priority Free Agent"
return {
'name': qb_name,
'evaluation': eval_result,
'strengths': strengths,
'weaknesses': weaknesses,
'draft_grade': grade,
'composite_score': composite
}
7.9 Summary
This chapter covered advanced passing metrics that go beyond traditional statistics:
- Expected Points Added (EPA): Measures the value of each pass by considering game situation
- Completion Probability Models: Predict expected completion rates based on pass difficulty
- CPOE: Measures how much a QB exceeds (or falls short of) expected completion rate
- Air Yards Analysis: Reveals passing tendencies and aggressiveness
- Adjusted Metrics: Account for pressure, opponent strength, and other context
- Comprehensive Evaluation: Combines multiple metrics for complete assessment
Key Takeaways
- Traditional metrics like completion percentage don't account for throw difficulty
- CPOE reveals true accuracy by comparing to expected performance
- Air yards metrics show passing style and aggressiveness
- Pressure-adjusted stats help isolate QB skill from offensive line performance
- Composite metrics provide balanced overall assessment
Exercises
See the accompanying exercises.md file for practice problems.
Further Reading
See further-reading.md for additional resources on advanced passing analysis.