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:

  1. Is this a real decline or statistical noise?
  2. What's causing the struggles?
  3. 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:

  1. Pressure is the primary culprit: Pressure rate increased from 28.7% to 43.2%

  2. Play-calling became conservative: aDOT dropped from 9.0 to 5.9 yards, reducing value creation

  3. QB shows some decline: Clean pocket EPA dropped from 0.22 to 0.12, but this may be confidence-related

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