Case Study 2: Diagnosing a Quarterback's Midseason Slump
Overview
In this case study, you'll use advanced passing metrics to diagnose why a starting quarterback's performance declined midseason and recommend solutions. This mirrors real analytics work where teams must identify whether struggles are due to the QB, supporting cast, play-calling, or opponents.
The Scenario
Marcus Thompson, the starting quarterback for State University, started the season brilliantly but has struggled over the past four games. The coaching staff wants to understand:
- Is this a real decline or statistical noise?
- What's causing the struggles?
- What adjustments should be made?
Part 1: The Raw Data
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple
# Game-by-game statistics for Marcus Thompson
game_log = [
# Week, Opponent, Result, Comp, Att, Yards, TD, INT, Sacks
{'week': 1, 'opponent': 'FCS Opponent', 'result': 'W 42-7',
'comp': 22, 'att': 28, 'yards': 312, 'td': 4, 'int': 0, 'sacks': 1,
'pressures': 6, 'dropbacks': 29, 'adot': 9.2, 'epa_total': 8.5},
{'week': 2, 'opponent': 'Mid-Major A', 'result': 'W 35-17',
'comp': 24, 'att': 32, 'yards': 298, 'td': 3, 'int': 0, 'sacks': 2,
'pressures': 8, 'dropbacks': 34, 'adot': 8.8, 'epa_total': 6.2},
{'week': 3, 'opponent': 'Conference Rival', 'result': 'W 28-21',
'comp': 21, 'att': 35, 'yards': 275, 'td': 2, 'int': 1, 'sacks': 3,
'pressures': 12, 'dropbacks': 38, 'adot': 8.5, 'epa_total': 3.8},
{'week': 4, 'opponent': 'Ranked Opponent', 'result': 'W 31-28',
'comp': 26, 'att': 38, 'yards': 342, 'td': 3, 'int': 1, 'sacks': 4,
'pressures': 15, 'dropbacks': 42, 'adot': 9.5, 'epa_total': 5.1},
# === MIDSEASON SLUMP BEGINS ===
{'week': 5, 'opponent': 'Division Leader', 'result': 'L 21-35',
'comp': 18, 'att': 38, 'yards': 198, 'td': 1, 'int': 2, 'sacks': 5,
'pressures': 18, 'dropbacks': 43, 'adot': 6.2, 'epa_total': -4.2},
{'week': 6, 'opponent': 'Conference Foe', 'result': 'L 17-24',
'comp': 22, 'att': 42, 'yards': 215, 'td': 1, 'int': 2, 'sacks': 6,
'pressures': 20, 'dropbacks': 48, 'adot': 5.8, 'epa_total': -5.8},
{'week': 7, 'opponent': 'Mid-Pack Team', 'result': 'W 24-21',
'comp': 20, 'att': 35, 'yards': 225, 'td': 2, 'int': 1, 'sacks': 4,
'pressures': 16, 'dropbacks': 39, 'adot': 6.5, 'epa_total': 0.5},
{'week': 8, 'opponent': 'Ranked Opponent 2', 'result': 'L 14-28',
'comp': 15, 'att': 32, 'yards': 168, 'td': 1, 'int': 3, 'sacks': 7,
'pressures': 19, 'dropbacks': 39, 'adot': 5.2, 'epa_total': -8.5}
]
# Convert to DataFrame
df = pd.DataFrame(game_log)
# Calculate traditional metrics
df['comp_pct'] = (df['comp'] / df['att'] * 100).round(1)
df['ypa'] = (df['yards'] / df['att']).round(2)
df['pressure_rate'] = (df['pressures'] / df['dropbacks'] * 100).round(1)
df['sack_rate'] = (df['sacks'] / df['dropbacks'] * 100).round(1)
df['epa_per_db'] = (df['epa_total'] / df['dropbacks']).round(3)
print("=" * 80)
print("MARCUS THOMPSON - 2024 SEASON GAME LOG")
print("=" * 80)
print(df[['week', 'opponent', 'result', 'comp_pct', 'ypa', 'td', 'int', 'epa_per_db']].to_string(index=False))
Part 2: Identifying the Decline
class PerformanceAnalyzer:
"""Analyze quarterback performance trends."""
def __init__(self, game_log: List[Dict]):
self.df = pd.DataFrame(game_log)
self._calculate_metrics()
def _calculate_metrics(self):
"""Calculate all relevant metrics."""
self.df['comp_pct'] = self.df['comp'] / self.df['att'] * 100
self.df['ypa'] = self.df['yards'] / self.df['att']
self.df['pressure_rate'] = self.df['pressures'] / self.df['dropbacks'] * 100
self.df['sack_rate'] = self.df['sacks'] / self.df['dropbacks'] * 100
self.df['epa_per_db'] = self.df['epa_total'] / self.df['dropbacks']
def split_analysis(self, split_week: int) -> Dict:
"""Compare performance before and after a given week."""
before = self.df[self.df['week'] < split_week]
after = self.df[self.df['week'] >= split_week]
def aggregate(subset):
if len(subset) == 0:
return {}
return {
'games': len(subset),
'comp_pct': round(subset['comp'].sum() / subset['att'].sum() * 100, 1),
'ypa': round(subset['yards'].sum() / subset['att'].sum(), 2),
'td_per_game': round(subset['td'].sum() / len(subset), 2),
'int_per_game': round(subset['int'].sum() / len(subset), 2),
'pressure_rate': round(subset['pressures'].sum() / subset['dropbacks'].sum() * 100, 1),
'sack_rate': round(subset['sacks'].sum() / subset['dropbacks'].sum() * 100, 1),
'avg_adot': round(subset['adot'].mean(), 2),
'epa_per_db': round(subset['epa_total'].sum() / subset['dropbacks'].sum(), 3)
}
before_stats = aggregate(before)
after_stats = aggregate(after)
# Calculate changes
changes = {}
for key in before_stats:
if key != 'games' and key in after_stats:
changes[key] = round(after_stats[key] - before_stats[key], 2)
return {
'first_half': before_stats,
'second_half': after_stats,
'changes': changes
}
def identify_root_causes(self, split_week: int) -> List[str]:
"""Identify potential root causes of performance change."""
analysis = self.split_analysis(split_week)
changes = analysis['changes']
causes = []
# Check pressure increase
if changes.get('pressure_rate', 0) > 8:
causes.append({
'factor': 'PRESSURE INCREASE',
'magnitude': f"+{changes['pressure_rate']}% pressure rate",
'explanation': 'Offensive line protection has deteriorated significantly'
})
# Check aDOT decrease
if changes.get('avg_adot', 0) < -2:
causes.append({
'factor': 'SHORTER PASSES',
'magnitude': f"{changes['avg_adot']:.1f} yard aDOT decrease",
'explanation': 'Either play-calling is more conservative or pressure forces quick throws'
})
# Check sack rate
if changes.get('sack_rate', 0) > 5:
causes.append({
'factor': 'SACK RATE SPIKE',
'magnitude': f"+{changes['sack_rate']}% sack rate",
'explanation': 'QB is holding ball too long or line is failing'
})
# Check EPA decline
if changes.get('epa_per_db', 0) < -0.15:
causes.append({
'factor': 'VALUE CREATION DROP',
'magnitude': f"{changes['epa_per_db']:.3f} EPA/dropback decline",
'explanation': 'Overall passing game is producing significantly less value'
})
return causes
# Run analysis
analyzer = PerformanceAnalyzer(game_log)
split = analyzer.split_analysis(split_week=5)
print("\n" + "=" * 80)
print("PERFORMANCE SPLIT: WEEKS 1-4 vs WEEKS 5-8")
print("=" * 80)
print(f"\n{'Metric':<20} {'Weeks 1-4':>12} {'Weeks 5-8':>12} {'Change':>12}")
print("-" * 60)
metrics_to_show = ['comp_pct', 'ypa', 'td_per_game', 'int_per_game',
'pressure_rate', 'sack_rate', 'avg_adot', 'epa_per_db']
for metric in metrics_to_show:
first = split['first_half'].get(metric, 0)
second = split['second_half'].get(metric, 0)
change = split['changes'].get(metric, 0)
if metric in ['pressure_rate', 'sack_rate', 'int_per_game']:
# Higher is worse
change_str = f"{change:+.2f}" if change >= 0 else f"{change:.2f}"
indicator = "⚠️" if change > 0 else "✓"
else:
# Higher is better
change_str = f"{change:+.2f}" if change > 0 else f"{change:.2f}"
indicator = "✓" if change > 0 else "⚠️"
print(f"{metric:<20} {first:>12.2f} {second:>12.2f} {change_str:>10} {indicator}")
Part 3: Root Cause Analysis
# Detailed root cause investigation
print("\n" + "=" * 80)
print("ROOT CAUSE ANALYSIS")
print("=" * 80)
causes = analyzer.identify_root_causes(split_week=5)
for i, cause in enumerate(causes, 1):
print(f"\n{i}. {cause['factor']}")
print(f" Magnitude: {cause['magnitude']}")
print(f" Explanation: {cause['explanation']}")
# Deep dive into pressure analysis
print("\n" + "-" * 80)
print("PRESSURE DEEP DIVE")
print("-" * 80)
# Simulated detailed pressure data
pressure_analysis = {
'weeks_1_4': {
'total_pressures': 41,
'total_dropbacks': 143,
'pressure_rate': 28.7,
'sacks': 10,
'hurries': 21,
'hits': 10,
'comp_when_clean': 68.5,
'comp_when_pressured': 42.1,
'epa_when_clean': 0.22,
'epa_when_pressured': -0.08
},
'weeks_5_8': {
'total_pressures': 73,
'total_dropbacks': 169,
'pressure_rate': 43.2,
'sacks': 22,
'hurries': 32,
'hits': 19,
'comp_when_clean': 62.4,
'comp_when_pressured': 35.8,
'epa_when_clean': 0.12,
'epa_when_pressured': -0.25
}
}
print(f"\n{'Metric':<25} {'Weeks 1-4':>12} {'Weeks 5-8':>12}")
print("-" * 55)
for metric in pressure_analysis['weeks_1_4']:
v1 = pressure_analysis['weeks_1_4'][metric]
v2 = pressure_analysis['weeks_5_8'][metric]
print(f"{metric:<25} {v1:>12.1f} {v2:>12.1f}")
# Air yards breakdown
print("\n" + "-" * 80)
print("AIR YARDS ANALYSIS")
print("-" * 80)
air_yards_analysis = {
'weeks_1_4': {
'avg_adot': 9.0,
'deep_pass_pct': 22.5,
'deep_comp_pct': 48.2,
'short_pass_pct': 42.5,
'medium_pass_pct': 35.0,
'air_yards_share': 72.5
},
'weeks_5_8': {
'avg_adot': 5.9,
'deep_pass_pct': 12.8,
'deep_comp_pct': 32.4,
'short_pass_pct': 58.2,
'medium_pass_pct': 29.0,
'air_yards_share': 48.2
}
}
print(f"\n{'Metric':<25} {'Weeks 1-4':>12} {'Weeks 5-8':>12}")
print("-" * 55)
for metric in air_yards_analysis['weeks_1_4']:
v1 = air_yards_analysis['weeks_1_4'][metric]
v2 = air_yards_analysis['weeks_5_8'][metric]
print(f"{metric:<25} {v1:>12.1f} {v2:>12.1f}")
Part 4: Is This the QB's Fault?
class FaultAttribution:
"""Determine how much of the decline is due to the QB vs other factors."""
def __init__(self, early_stats: Dict, late_stats: Dict,
pressure_data: Dict, air_data: Dict):
self.early = early_stats
self.late = late_stats
self.pressure = pressure_data
self.air = air_data
def calculate_attribution(self) -> Dict:
"""Calculate contribution of each factor to the decline."""
attributions = {}
# 1. Offensive Line Attribution
# If pressure increased but QB performance under pressure is similar,
# line is at fault
pressure_increase = (self.pressure['weeks_5_8']['pressure_rate'] -
self.pressure['weeks_1_4']['pressure_rate'])
# Estimate EPA lost due to additional pressures
additional_pressures = self.pressure['weeks_5_8']['total_pressures'] - \
(self.pressure['weeks_1_4']['pressure_rate'] / 100 *
self.pressure['weeks_5_8']['total_dropbacks'])
epa_loss_from_pressure = additional_pressures * -0.15 # Avg EPA loss per pressure
attributions['offensive_line'] = {
'metric': 'Increased pressure rate',
'change': f"+{pressure_increase:.1f}%",
'estimated_epa_impact': round(epa_loss_from_pressure, 1),
'attribution_pct': 45
}
# 2. Play-calling Attribution
# aDOT decrease suggests conservative play-calling
adot_decrease = (self.air['weeks_5_8']['avg_adot'] -
self.air['weeks_1_4']['avg_adot'])
attributions['play_calling'] = {
'metric': 'Decreased aDOT',
'change': f"{adot_decrease:.1f} yards",
'estimated_epa_impact': -3.5, # Less aggressive = less value
'attribution_pct': 25
}
# 3. QB Attribution
# Look at clean pocket performance decline
clean_pocket_decline = (self.pressure['weeks_5_8']['epa_when_clean'] -
self.pressure['weeks_1_4']['epa_when_clean'])
attributions['quarterback'] = {
'metric': 'Clean pocket EPA decline',
'change': f"{clean_pocket_decline:.2f} EPA/play",
'estimated_epa_impact': clean_pocket_decline * 50,
'attribution_pct': 20
}
# 4. Opponent Quality
attributions['opponents'] = {
'metric': 'Schedule difficulty',
'change': 'Faced 3 ranked opponents in weeks 5-8 vs 1 in weeks 1-4',
'estimated_epa_impact': -2.0,
'attribution_pct': 10
}
return attributions
def generate_verdict(self) -> str:
"""Generate overall verdict on the slump."""
attributions = self.calculate_attribution()
total_qb_fault = attributions['quarterback']['attribution_pct']
total_external = 100 - total_qb_fault
return f"""
╔══════════════════════════════════════════════════════════════════════════╗
║ DECLINE ATTRIBUTION ║
╠══════════════════════════════════════════════════════════════════════════╣
║ Factor │ Change │ EPA Impact │ Attribution ║
╠══════════════════════════════════════════════════════════════════════════╣
║ Offensive Line │ +14.5% pressure rate │ -5.2 │ 45% ║
║ Play-Calling │ -3.1 yard aDOT │ -3.5 │ 25% ║
║ Quarterback │ -0.10 clean EPA │ -5.0 │ 20% ║
║ Opponents │ Tougher schedule │ -2.0 │ 10% ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
║ VERDICT: The decline is primarily NOT the quarterback's fault. ║
║ ║
║ • 80% of the decline is attributable to external factors ║
║ • Offensive line deterioration is the primary culprit ║
║ • Play-calling has become overly conservative (possibly due to pressure) ║
║ • QB shows some decline in clean pocket (possible confidence issue) ║
║ ║
║ RECOMMENDATION: Address offensive line before considering QB change ║
╚══════════════════════════════════════════════════════════════════════════╝
"""
# Run attribution analysis
attribution = FaultAttribution(
split['first_half'], split['second_half'],
pressure_analysis, air_yards_analysis
)
print(attribution.generate_verdict())
Part 5: Actionable Recommendations
def generate_recommendations(pressure_data: Dict, air_data: Dict,
performance_split: Dict) -> str:
"""Generate actionable recommendations based on analysis."""
recommendations = """
╔══════════════════════════════════════════════════════════════════════════╗
║ ACTIONABLE RECOMMENDATIONS ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
║ IMMEDIATE (This Week): ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ 1. OFFENSIVE LINE ADJUSTMENTS ║
║ • Move to max-protect schemes on longer developing plays ║
║ • Chip with tight ends/running backs before routes ║
║ • Reduce 5-wide sets that leave QB exposed ║
║ ║
║ 2. QUICK GAME MIXED WITH AGGRESSION ║
║ • Use quick RPOs to get ball out fast ║
║ • BUT maintain 3-4 shot plays per game to keep defense honest ║
║ • Current aDOT of 5.9 is too conservative ║
║ ║
║ SHORT-TERM (Next 2-3 Games): ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ 3. REBUILD QB CONFIDENCE ║
║ • Script first 5 plays with high-probability completions ║
║ • Include 1-2 designed rollouts per quarter for clean throws ║
║ • Film session focusing on successful plays from early season ║
║ ║
║ 4. MOTION AND MISDIRECTION ║
║ • Increase pre-snap motion to identify coverage ║
║ • Use play-action more (currently underused) ║
║ • Help QB with pre-snap reads to speed up processing ║
║ ║
║ PERSONNEL CONSIDERATION: ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ 5. LINE PERSONNEL ║
║ • Evaluate starting RT - highest pressure allowed rate on team ║
║ • Consider moving starting G to RT for better pass protection ║
║ • Healthy scratch of underperforming linemen may send message ║
║ ║
║ DO NOT RECOMMEND: ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ ✗ Benching Marcus Thompson ║
║ Analysis shows 80% of decline from external factors ║
║ ║
║ ✗ Further reducing aDOT / going entirely to checkdowns ║
║ This would continue to reduce value creation ║
║ ║
║ ✗ Blaming QB for interceptions without context ║
║ 5 of 8 INTs came under pressure (62.5%) ║
╚══════════════════════════════════════════════════════════════════════════╝
"""
return recommendations
print(generate_recommendations(pressure_analysis, air_yards_analysis, split))
Part 6: Monitoring Going Forward
def create_monitoring_dashboard(key_metrics: List[str]) -> str:
"""Create a dashboard for ongoing monitoring."""
dashboard = """
╔══════════════════════════════════════════════════════════════════════════╗
║ ONGOING MONITORING DASHBOARD ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
║ KEY METRICS TO TRACK WEEKLY: ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ ║
║ Metric │ Target │ Alert If │ Current │ Trend ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ Pressure Rate │ < 30% │ > 35% │ 43.2% │ ⚠️ HIGH ║
║ Clean Pocket EPA │ > 0.15 │ < 0.10 │ 0.12 │ ⚠️ WATCH ║
║ aDOT │ > 8.0 │ < 7.0 │ 5.9 │ ⚠️ LOW ║
║ Sack Rate │ < 8% │ > 10% │ 13.0% │ ⚠️ HIGH ║
║ EPA per Dropback │ > 0.10 │ < 0.05 │ -0.11 │ ⚠️ ALERT ║
║ CPOE │ > +2% │ < -2% │ TBD │ MONITOR ║
║ ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
║ WEEKLY REVIEW CHECKLIST: ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ □ Calculate game-by-game EPA split by pressure ║
║ □ Track aDOT trend - is it recovering? ║
║ □ Monitor clean pocket performance for QB regression ║
║ □ Chart pressure by down and distance ║
║ □ Identify if specific linemen are causing breakdowns ║
║ ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
║ SUCCESS INDICATORS (Recovery Signs): ║
║ ─────────────────────────────────────────────────────────────────────── ║
║ ✓ Pressure rate drops below 35% ║
║ ✓ aDOT returns to 8.0+ yards ║
║ ✓ EPA per dropback turns positive ║
║ ✓ Clean pocket EPA returns to 0.15+ ║
║ ✓ Deep ball attempts increase without major INT spike ║
║ ║
╚══════════════════════════════════════════════════════════════════════════╝
"""
return dashboard
print(create_monitoring_dashboard([]))
Summary
This case study demonstrated how advanced passing metrics can diagnose performance issues:
Key Findings:
-
Pressure is the primary culprit: Pressure rate increased from 28.7% to 43.2%
-
Play-calling became conservative: aDOT dropped from 9.0 to 5.9 yards, reducing value creation
-
QB shows some decline: Clean pocket EPA dropped from 0.22 to 0.12, but this may be confidence-related
-
Attribution: 80% external factors, 20% QB
Methodology Demonstrated:
- Split analysis to identify when performance changed
- Pressure metrics to isolate line vs QB issues
- Air yards analysis to evaluate play-calling impact
- Attribution modeling to assign responsibility
- Actionable recommendations based on root causes
- Monitoring framework for ongoing evaluation
Real-World Application:
This type of analysis helps teams: - Avoid knee-jerk reactions (benching a good QB) - Identify true problem areas - Make targeted adjustments - Communicate decisions with data backing