4 min read

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

Chapter 7: Advanced Passing Metrics

Learning Objectives

By the end of this chapter, you will be able to:

  1. Understand the limitations of traditional passing statistics
  2. Calculate and interpret Expected Points Added (EPA) for passing plays
  3. Implement completion probability models
  4. Calculate Completion Percentage Over Expected (CPOE)
  5. Analyze air yards, yards after catch, and depth of target
  6. Build adjusted passing metrics that account for context
  7. 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

  1. Expected Points Added (EPA): Measuring the value of each pass
  2. Completion Probability Models: What should a QB complete?
  3. CPOE: Completion Percentage Over Expected
  4. Air Yards Analysis: Understanding target depth
  5. Adjusted Metrics: Context-aware quarterback evaluation
  6. 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

  1. Air Yards (Depth of Target): Deeper passes are harder to complete
  2. Receiver Separation: More separation = higher completion probability
  3. Pressure: Passes under pressure are harder to complete
  4. Down and Distance: Affects defensive coverage
  5. Score Differential: Garbage time vs. close games
  6. 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:

  1. Expected Points Added (EPA): Measures the value of each pass by considering game situation
  2. Completion Probability Models: Predict expected completion rates based on pass difficulty
  3. CPOE: Measures how much a QB exceeds (or falls short of) expected completion rate
  4. Air Yards Analysis: Reveals passing tendencies and aggressiveness
  5. Adjusted Metrics: Account for pressure, opponent strength, and other context
  6. 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.