Case Study: The Free Agent Quarterback Decision
"The right quarterback can transform a franchise. The wrong one can set you back a decade."
Executive Summary
Your team needs a quarterback. Two free agents are available, and you have the cap space to sign either. This case study applies comprehensive QB evaluation to inform a $150M+ decision.
Skills Applied: - Multi-metric QB evaluation - Context adjustment - Supporting cast analysis - Risk assessment - Decision framework construction
The Scenario
Your Team Situation: - Current QB: Aging veteran, 0.02 EPA/play (league average) - Offensive Line: Top 10 in pass protection - Receivers: Young, developing corps - Cap Space: $50M available - Draft Capital: Picks 18 and 50
The Free Agents:
| Attribute | QB Alpha | QB Beta |
|---|---|---|
| Age | 28 | 31 |
| Experience | 6 seasons | 9 seasons |
| Last 2 Season EPA | 0.15 | 0.12 |
| CPOE | +3.2% | +1.8% |
| ADOT | 9.2 | 7.4 |
| Career Games Started | 85 | 128 |
| Expected Contract | $45M/year | $35M/year |
Part 1: Data Gathering
Step 1.1: Load and Prepare Data
import pandas as pd
import numpy as np
import nfl_data_py as nfl
from scipy import stats
import matplotlib.pyplot as plt
# Load multiple seasons
seasons = [2021, 2022, 2023]
pbp = nfl.import_pbp_data(seasons)
# For this case study, we'll use real QBs as proxies
# QB Alpha = aggressive, accurate passer (e.g., Mahomes-style)
# QB Beta = conservative, efficient passer (e.g., Prescott-style)
def get_qb_profile(pbp, qb_name, seasons):
"""Extract comprehensive QB profile."""
passes = pbp.query(
f"pass == 1 and passer_player_name.str.contains('{qb_name}', na=False)"
)
profile = {}
for season in seasons:
season_passes = passes.query(f"season == {season}")
if len(season_passes) >= 200:
profile[season] = {
'dropbacks': len(season_passes),
'epa': season_passes['epa'].mean(),
'cpoe': season_passes['cpoe'].mean(),
'adot': season_passes['air_yards'].mean(),
'success_rate': season_passes['success'].mean(),
'int_rate': season_passes['interception'].mean(),
'sack_rate': season_passes['sack'].mean()
}
return profile
Step 1.2: Baseline Metrics
def compare_qbs(pbp, qb_alpha_name, qb_beta_name):
"""Generate side-by-side comparison."""
metrics = ['epa', 'cpoe', 'success_rate', 'adot', 'int_rate', 'sack_rate']
results = {}
for qb in [qb_alpha_name, qb_beta_name]:
passes = pbp.query(f"pass == 1 and passer_player_name.str.contains('{qb}', na=False)")
results[qb] = {
'dropbacks': len(passes),
'epa': passes['epa'].mean(),
'epa_total': passes['epa'].sum(),
'cpoe': passes['cpoe'].mean(),
'success_rate': passes['success'].mean(),
'adot': passes['air_yards'].mean(),
'yac': passes['yards_after_catch'].mean(),
'int_rate': passes['interception'].mean(),
'sack_rate': passes['sack'].mean(),
'deep_pct': (passes['air_yards'] >= 20).mean()
}
return pd.DataFrame(results).T
Part 2: Deep Dive Analysis
Step 2.1: Stability Assessment
def analyze_stability(pbp, qb_name, window=100):
"""Analyze performance stability over rolling windows."""
passes = pbp.query(
f"pass == 1 and passer_player_name.str.contains('{qb_name}', na=False)"
).sort_values(['season', 'week', 'play_id'])
passes['rolling_epa'] = passes['epa'].rolling(window, min_periods=50).mean()
passes['rolling_cpoe'] = passes['cpoe'].rolling(window, min_periods=50).mean()
passes['play_num'] = range(len(passes))
# Calculate variance metrics
weekly_epa = passes.groupby(['season', 'week'])['epa'].mean()
stability_metrics = {
'epa_std': weekly_epa.std(),
'epa_range': weekly_epa.max() - weekly_epa.min(),
'games_below_zero': (weekly_epa < 0).mean(),
'volatility': weekly_epa.diff().std()
}
return passes, stability_metrics
Step 2.2: Situational Performance
def situational_analysis(pbp, qb_name):
"""Analyze performance across game situations."""
passes = pbp.query(
f"pass == 1 and passer_player_name.str.contains('{qb_name}', na=False)"
)
situations = {
'early_downs': 'down <= 2',
'third_down': 'down == 3',
'red_zone': 'yardline_100 <= 20',
'two_minute': 'half_seconds_remaining <= 120',
'trailing': 'score_differential < 0',
'leading': 'score_differential > 0',
'close_game': 'abs(score_differential) <= 7',
'pressure': 'sack == 1 or qb_hit == 1' # Proxy for pressure
}
results = {}
for situation, filter_expr in situations.items():
try:
subset = passes.query(filter_expr)
if len(subset) >= 30:
results[situation] = {
'plays': len(subset),
'epa': subset['epa'].mean(),
'success_rate': subset['success'].mean()
}
except:
pass
return pd.DataFrame(results).T
Step 2.3: Supporting Cast Impact
def estimate_cast_impact(pbp, qb_name):
"""Estimate how much of EPA is from supporting cast."""
passes = pbp.query(
f"pass == 1 and passer_player_name.str.contains('{qb_name}', na=False)"
)
# Team's YAC compared to league
team_yac = passes['yards_after_catch'].mean()
league_yac = pbp.query("pass == 1")['yards_after_catch'].mean()
yac_advantage = team_yac - league_yac
# Sack rate compared to league
team_sack = passes['sack'].mean()
league_sack = pbp.query("pass == 1")['sack'].mean()
protection_advantage = league_sack - team_sack # Lower is better
# Air yards EPA (more QB-attributable)
air_epa = passes[passes['complete_pass'] == 1].apply(
lambda x: x['epa'] * (x['air_yards'] / x['yards_gained']) if x['yards_gained'] > 0 else 0,
axis=1
).mean()
return {
'yac_advantage': yac_advantage,
'protection_advantage': protection_advantage,
'total_epa': passes['epa'].mean(),
'estimated_cast_contribution': yac_advantage * 0.03, # Rough estimate
'qb_isolated_epa': passes['epa'].mean() - (yac_advantage * 0.03)
}
Part 3: Risk Assessment
Step 3.1: Injury and Durability
def durability_analysis(qb_profile):
"""Assess durability based on games played."""
# Calculate games per season
games_by_season = [
data['dropbacks'] // 30 # Approximate games from dropbacks
for season, data in qb_profile.items()
]
return {
'avg_games_per_season': np.mean(games_by_season),
'min_games': min(games_by_season),
'seasons_below_12_games': sum(g < 12 for g in games_by_season),
'durability_score': np.mean(games_by_season) / 17 * 100
}
Step 3.2: Age Curve Analysis
def project_decline(current_epa, current_age, target_age):
"""Project future performance based on age curves."""
# Typical QB age curve (based on research)
# Peak around 27-29, gradual decline after 32
age_adjustments = {
27: 0.00, 28: 0.00, 29: -0.01, 30: -0.02,
31: -0.03, 32: -0.04, 33: -0.06, 34: -0.08,
35: -0.10, 36: -0.12
}
projections = {}
for year in range(5):
future_age = current_age + year
adjustment = age_adjustments.get(future_age, -0.15)
projections[f"Year {year + 1}"] = {
'age': future_age,
'projected_epa': current_epa + adjustment,
'adjustment': adjustment
}
return projections
Step 3.3: Floor/Ceiling Analysis
def floor_ceiling_analysis(pbp, qb_name, n_games=100):
"""Estimate performance floor and ceiling."""
passes = pbp.query(
f"pass == 1 and passer_player_name.str.contains('{qb_name}', na=False)"
)
# Game-by-game EPA
game_epa = passes.groupby(['season', 'week'])['epa'].mean()
return {
'floor': game_epa.quantile(0.10),
'median': game_epa.quantile(0.50),
'ceiling': game_epa.quantile(0.90),
'worst_game': game_epa.min(),
'best_game': game_epa.max(),
'games_above_0.2': (game_epa > 0.2).mean(),
'games_below_0': (game_epa < 0).mean()
}
Part 4: Financial Analysis
Step 4.1: Value Over Replacement
def calculate_value(epa_per_play, dropbacks_per_season=550):
"""
Estimate wins and dollars added over replacement.
Assumptions:
- Replacement-level QB: -0.05 EPA/play
- 1 EPA ≈ 0.037 wins (rough estimate)
- 1 win ≈ $2.5M in free agency
"""
replacement_epa = -0.05
epa_over_replacement = epa_per_play - replacement_epa
total_epa_added = epa_over_replacement * dropbacks_per_season
wins_added = total_epa_added * 0.037
dollar_value = wins_added * 2.5 # Millions
return {
'epa_over_replacement': epa_over_replacement,
'season_epa_added': total_epa_added,
'wins_added': wins_added,
'dollar_value_millions': dollar_value
}
# Compare values
alpha_value = calculate_value(0.15)
beta_value = calculate_value(0.12)
print("QB Alpha Value:")
print(f" Wins added: {alpha_value['wins_added']:.1f}")
print(f" Dollar value: ${alpha_value['dollar_value_millions']:.1f}M")
print("\nQB Beta Value:")
print(f" Wins added: {beta_value['wins_added']:.1f}")
print(f" Dollar value: ${beta_value['dollar_value_millions']:.1f}M")
Step 4.2: Contract Value Assessment
def contract_analysis(qb_value, contract_aav, contract_years=4):
"""Assess contract value relative to production."""
total_contract = contract_aav * contract_years
# Project total value over contract
# Assume 3% annual decline in value
projected_values = []
current_value = qb_value['dollar_value_millions']
for year in range(contract_years):
projected_values.append(current_value)
current_value *= 0.97
total_projected_value = sum(projected_values)
surplus_value = total_projected_value - total_contract
return {
'contract_aav': contract_aav,
'contract_total': total_contract,
'projected_value': total_projected_value,
'surplus_value': surplus_value,
'value_per_dollar': total_projected_value / total_contract
}
Part 5: Decision Framework
Step 5.1: Weighted Scoring Model
def build_decision_matrix(alpha_metrics, beta_metrics, weights=None):
"""
Build weighted decision matrix for QB comparison.
Parameters
----------
alpha_metrics : dict
QB Alpha metrics
beta_metrics : dict
QB Beta metrics
weights : dict
Weight for each category
Returns
-------
pd.DataFrame
Decision matrix with scores
"""
if weights is None:
weights = {
'efficiency': 0.30, # EPA, success rate
'accuracy': 0.20, # CPOE
'upside': 0.15, # Ceiling, aggressiveness
'floor': 0.15, # Consistency, floor
'durability': 0.10, # Games played, age
'value': 0.10 # Surplus value
}
categories = {
'efficiency': {
'alpha': (alpha_metrics['epa'] - 0) / 0.3, # Normalized
'beta': (beta_metrics['epa'] - 0) / 0.3
},
'accuracy': {
'alpha': (alpha_metrics['cpoe'] + 5) / 10,
'beta': (beta_metrics['cpoe'] + 5) / 10
},
'upside': {
'alpha': 0.8, # Higher variance, higher ceiling
'beta': 0.6
},
'floor': {
'alpha': 0.6, # Higher variance means lower floor
'beta': 0.8
},
'durability': {
'alpha': 0.8, # Younger
'beta': 0.7
},
'value': {
'alpha': 0.6, # More expensive
'beta': 0.75
}
}
# Calculate weighted scores
alpha_total = sum(
categories[cat]['alpha'] * weights[cat]
for cat in categories
)
beta_total = sum(
categories[cat]['beta'] * weights[cat]
for cat in categories
)
return {
'alpha_score': alpha_total,
'beta_score': beta_total,
'categories': categories,
'weights': weights
}
Step 5.2: Scenario Analysis
def scenario_analysis():
"""Analyze decision under different scenarios."""
scenarios = {
'base_case': {
'description': 'Both perform as expected',
'alpha_outcome': 'Consistent elite play, 0.14 EPA',
'beta_outcome': 'Consistent above-average, 0.11 EPA'
},
'alpha_upside': {
'description': 'Alpha reaches peak potential',
'alpha_outcome': 'MVP-level, 0.20+ EPA',
'beta_outcome': 'Same as base case'
},
'alpha_downside': {
'description': 'Alpha regresses or injured',
'alpha_outcome': 'Average, 0.05 EPA or missed time',
'beta_outcome': 'Same as base case'
},
'beta_consistency': {
'description': 'Beta proves ultra-reliable',
'alpha_outcome': 'Same as base case',
'beta_outcome': '0.12 EPA every year, never injured'
},
'age_decline': {
'description': 'Both follow age curves',
'alpha_outcome': '3+ elite years before decline',
'beta_outcome': '1-2 good years then steep decline'
}
}
print("SCENARIO ANALYSIS")
print("=" * 60)
for scenario, details in scenarios.items():
print(f"\n{scenario.upper()}: {details['description']}")
print(f" Alpha: {details['alpha_outcome']}")
print(f" Beta: {details['beta_outcome']}")
Part 6: Recommendation
Step 6.1: Final Summary
def generate_recommendation():
"""Generate final recommendation report."""
report = """
QUARTERBACK FREE AGENT ANALYSIS
===============================
EXECUTIVE SUMMARY
-----------------
After comprehensive analysis of QB Alpha and QB Beta, we recommend:
**PRIMARY RECOMMENDATION: QB ALPHA**
RATIONALE:
1. EFFICIENCY ADVANTAGE
- Alpha: 0.15 EPA/play (90th percentile)
- Beta: 0.12 EPA/play (75th percentile)
- 0.03 EPA/play difference = ~1.5 wins/season
2. ACCURACY EDGE
- Alpha CPOE: +3.2% (elite)
- Beta CPOE: +1.8% (above average)
- Alpha's accuracy is more sustainable long-term
3. UPSIDE POTENTIAL
- Alpha's aggressive style (9.2 ADOT) provides higher ceiling
- Aligns with our strong O-line (can protect deep drops)
- Young receivers benefit from aggressive QB
4. AGE ADVANTAGE
- Alpha (28) vs Beta (31)
- 3 more years of peak performance
- Better long-term asset
5. CONTRACT VALUE
- Despite higher AAV ($45M vs $35M)
- Higher production justifies premium
- Age advantage extends value window
RISKS TO CONSIDER:
- Higher variance with Alpha's style
- $10M more per year in cap space
- More aggressive style = more INTs
ALTERNATIVE: QB BETA
If risk tolerance is low:
- Beta provides more consistent floor
- Lower cap hit preserves flexibility
- Proven durability track record
Recommend Beta if: Need immediate cap flexibility for defense,
or if Alpha's medical evaluation reveals concerns.
FINAL VERDICT:
Invest in QB Alpha for ceiling; accept Beta for floor.
"""
print(report)
generate_recommendation()
Discussion Questions
-
Weighting Trade-offs: How would you change the decision matrix weights if your team was in "win-now" mode vs rebuilding?
-
Supporting Cast Fit: How does your team's specific personnel affect which QB is better?
-
Risk Tolerance: At what salary difference would you switch from Alpha to Beta?
-
Alternative Options: When should a team consider the draft instead of free agency?
-
Contract Structure: How would you structure each contract to minimize risk?
Extension: Build Your Own QB Decision Tool
Create a reusable tool that: 1. Inputs two QB names and cap constraints 2. Calculates all relevant metrics 3. Runs scenario analysis 4. Generates a recommendation report
class QBDecisionTool:
def __init__(self, pbp, cap_space, team_needs):
pass
def analyze_candidates(self, qb_list):
pass
def run_scenarios(self):
pass
def generate_report(self):
pass