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

  1. Weighting Trade-offs: How would you change the decision matrix weights if your team was in "win-now" mode vs rebuilding?

  2. Supporting Cast Fit: How does your team's specific personnel affect which QB is better?

  3. Risk Tolerance: At what salary difference would you switch from Alpha to Beta?

  4. Alternative Options: When should a team consider the draft instead of free agency?

  5. 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