6 min read

In a passing-dominated NFL, receivers have become the engine of offensive production. But evaluating pass-catchers presents unique challenges: How much of a reception reflects the receiver versus the quarterback? What separates true separation...

Chapter 8: Receiving Analytics

Chapter Overview

In a passing-dominated NFL, receivers have become the engine of offensive production. But evaluating pass-catchers presents unique challenges: How much of a reception reflects the receiver versus the quarterback? What separates true separation creators from scheme beneficiaries? How do we measure the value of contested catches, route running, or yards after catch? This chapter examines the full spectrum of receiving analytics—from target share and efficiency metrics to advanced concepts like separation, air yards, and catch rate over expected.

Learning Objectives

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

  1. Calculate and interpret EPA-based receiving metrics
  2. Understand target share and its relationship to production
  3. Analyze air yards, intended air yards, and RACR
  4. Evaluate catch rate over expected (CPOE-receiver)
  5. Decompose receiving production into air yards vs. YAC
  6. Compare receivers accounting for quarterback quality
  7. Build comprehensive receiver evaluation frameworks

8.1 The Challenge of Receiver Evaluation

Why Receiving is Hard to Evaluate

Unlike rushing, where the runner controls most of the action, receiving is fundamentally a collaborative activity:

  1. Quarterback dependence: The throw quality directly affects outcomes
  2. Scheme effects: Some receivers get open by design
  3. Target distribution: Volume is determined by others
  4. Opportunity variance: Red zone, third down, and deep shots vary widely

A receiver can run a perfect route but receive a poorly thrown ball. Another might get wide open due to scheme and benefit from an accurate QB. Separating individual contribution from team effects is the central challenge.

What We Can and Cannot Measure

Available in standard data: - Targets, receptions, yards, touchdowns - EPA on targets - Air yards and yards after catch - Completion percentage (catch rate)

Available in advanced/tracking data: - Separation (distance from defender at catch point) - Target separation (how open the receiver was) - Catch probability based on throw difficulty - Route depth and direction

Still difficult to measure: - Release quality off the line - Route-running precision - Contested catch ability (truly isolating it) - Blocking on running plays


8.2 Target-Based Metrics

Target Share: The Foundation

Target share measures what percentage of team passes go to a player:

import nfl_data_py as nfl
import pandas as pd
import numpy as np

def calculate_target_share(pbp: pd.DataFrame) -> pd.DataFrame:
    """Calculate target share for all receivers."""
    passes = pbp[pbp['pass_attempt'] == 1].copy()

    # Team targets
    team_targets = passes.groupby('posteam')['pass_attempt'].count()

    # Player targets
    player_targets = (passes
        .groupby(['posteam', 'receiver_player_name'])
        .agg(
            targets=('pass_attempt', 'count'),
            receptions=('complete_pass', 'sum'),
            yards=('yards_gained', 'sum'),
            tds=('pass_touchdown', 'sum')
        )
        .reset_index()
    )

    # Calculate share
    player_targets = player_targets.merge(
        team_targets.rename('team_targets'),
        left_on='posteam',
        right_index=True
    )
    player_targets['target_share'] = player_targets['targets'] / player_targets['team_targets']

    return player_targets.sort_values('target_share', ascending=False)

Interpreting Target Share

Target share thresholds:

Target Share Interpretation
> 25% Alpha/WR1
18-25% Strong WR2
12-18% WR3 or TE1
8-12% Rotational
< 8% Depth

Key insight: High target share typically correlates with production, but efficiency often decreases as volume rises (defenses focus on high-volume receivers).

Target Quality Metrics

Not all targets are equal:

def analyze_target_quality(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze the quality of targets received."""
    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards'])

    target_quality = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),

            # Depth metrics
            avg_air_yards=('air_yards', 'mean'),
            deep_targets=('air_yards', lambda x: (x >= 20).sum()),
            deep_target_pct=('air_yards', lambda x: (x >= 20).mean()),

            # Down/situation
            third_down_targets=('down', lambda x: (x == 3).sum()),
            red_zone_targets=('yardline_100', lambda x: (x <= 20).sum())
        )
        .query('targets >= 30')
    )

    return target_quality

8.3 EPA-Based Receiving Metrics

EPA per Target

The most straightforward efficiency metric:

def calculate_receiver_epa(pbp: pd.DataFrame, min_targets: int = 50) -> pd.DataFrame:
    """Calculate EPA-based receiving metrics."""
    passes = pbp[pbp['pass_attempt'] == 1].copy()

    receiver_stats = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            receptions=('complete_pass', 'sum'),
            yards=('yards_gained', 'sum'),
            total_epa=('epa', 'sum'),
            epa_per_target=('epa', 'mean'),
            success_rate=('epa', lambda x: (x > 0).mean()),
            touchdown_rate=('pass_touchdown', 'mean')
        )
        .query(f'targets >= {min_targets}')
        .sort_values('epa_per_target', ascending=False)
    )

    # Add catch rate
    receiver_stats['catch_rate'] = receiver_stats['receptions'] / receiver_stats['targets']

    return receiver_stats

EPA Interpretation for Receivers

EPA/Target Interpretation
> 0.40 Elite
0.20 to 0.40 Above average
0.00 to 0.20 Average
-0.20 to 0.00 Below average
< -0.20 Poor

Note: Unlike rushing (negative average), passing EPA is positive on average (~0.05), so receiver EPA benchmarks are higher.

Success Rate for Receivers

def receiver_success_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze receiver success rate patterns."""
    passes = pbp[pbp['pass_attempt'] == 1].copy()
    passes['success'] = passes['epa'] > 0

    success_stats = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            success_rate=('success', 'mean'),
            catch_rate=('complete_pass', 'mean'),
            epa_when_caught=('epa', lambda x: x[passes.loc[x.index, 'complete_pass'] == 1].mean())
        )
        .query('targets >= 50')
    )

    return success_stats

8.4 Air Yards and Receiving Depth

Understanding Air Yards

Air yards measure how far the ball travels in the air before the catch (or intended catch point):

  • Intended Air Yards (IAY): Air yards on all targets (complete or not)
  • Completed Air Yards (CAY): Air yards on completions only
  • ADOT (Average Depth of Target): Mean air yards per target
def analyze_air_yards(pbp: pd.DataFrame) -> pd.DataFrame:
    """Comprehensive air yards analysis."""
    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards'])

    air_yards_stats = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),

            # Air yards totals
            total_air_yards=('air_yards', 'sum'),
            avg_depth_of_target=('air_yards', 'mean'),

            # By completion
            completed_air_yards=('air_yards',
                lambda x: x[passes.loc[x.index, 'complete_pass'] == 1].sum()),

            # Depth distribution
            short_pct=('air_yards', lambda x: (x < 10).mean()),
            medium_pct=('air_yards', lambda x: ((x >= 10) & (x < 20)).mean()),
            deep_pct=('air_yards', lambda x: (x >= 20).mean())
        )
        .query('targets >= 50')
    )

    return air_yards_stats

RACR: Receiver Air Conversion Ratio

RACR compares actual receiving yards to air yards:

$$\text{RACR} = \frac{\text{Receiving Yards}}{\text{Air Yards}}$$

def calculate_racr(pbp: pd.DataFrame) -> pd.DataFrame:
    """Calculate RACR for receivers."""
    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards'])

    racr = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            total_air_yards=('air_yards', 'sum'),
            receiving_yards=('yards_gained', 'sum'),
            yac=('yards_after_catch', 'sum')
        )
        .query('targets >= 50')
    )

    racr['racr'] = racr['receiving_yards'] / racr['total_air_yards']

    return racr.sort_values('racr', ascending=False)

Interpreting RACR: - RACR > 1.0: Gaining more yards than targeted (YAC contribution) - RACR = 1.0: Perfect conversion of air yards to real yards - RACR < 1.0: Not converting air yards (drops, incompletions)

WOPR: Weighted Opportunity Rating

WOPR combines target share with air yards share:

def calculate_wopr(pbp: pd.DataFrame) -> pd.DataFrame:
    """Calculate WOPR (Weighted Opportunity Rating)."""
    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards'])

    # Team totals
    team_stats = passes.groupby('posteam').agg(
        team_targets=('pass_attempt', 'count'),
        team_air_yards=('air_yards', 'sum')
    )

    # Player stats
    player_stats = (passes
        .groupby(['posteam', 'receiver_player_name'])
        .agg(
            targets=('pass_attempt', 'count'),
            air_yards=('air_yards', 'sum')
        )
        .reset_index()
        .merge(team_stats, left_on='posteam', right_index=True)
    )

    # Calculate shares
    player_stats['target_share'] = player_stats['targets'] / player_stats['team_targets']
    player_stats['air_yards_share'] = player_stats['air_yards'] / player_stats['team_air_yards']

    # WOPR = 1.5 * target share + 0.7 * air yards share
    player_stats['wopr'] = (1.5 * player_stats['target_share'] +
                            0.7 * player_stats['air_yards_share'])

    return player_stats.sort_values('wopr', ascending=False)

8.5 Yards After Catch (YAC)

The YAC Component

YAC measures receiver contribution after the catch:

def analyze_yac(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze yards after catch patterns."""
    completions = pbp[(pbp['pass_attempt'] == 1) &
                      (pbp['complete_pass'] == 1)].copy()

    yac_stats = (completions
        .groupby('receiver_player_name')
        .agg(
            receptions=('complete_pass', 'count'),
            total_yards=('yards_gained', 'sum'),
            total_yac=('yards_after_catch', 'sum'),
            yac_per_rec=('yards_after_catch', 'mean'),
            yards_per_rec=('yards_gained', 'mean'),
            air_yards_per_rec=('air_yards', 'mean')
        )
        .query('receptions >= 30')
    )

    # Calculate YAC percentage
    yac_stats['yac_pct'] = yac_stats['total_yac'] / yac_stats['total_yards']

    return yac_stats.sort_values('yac_per_rec', ascending=False)

Receiver Styles Based on YAC

Receivers can be categorized by their YAC profile:

Profile Characteristics
YAC Monster High YAC/rec, lower ADOT, excels after catch
Field Stretcher High ADOT, lower YAC, wins deep
Possession Receiver Moderate everything, reliable chains
Slot Specialist High YAC, short targets, underneath routes
def categorize_receiver_style(pbp: pd.DataFrame) -> pd.DataFrame:
    """Categorize receivers by playing style."""
    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards', 'yards_after_catch'])

    style_stats = (passes
        [passes['complete_pass'] == 1]
        .groupby('receiver_player_name')
        .agg(
            receptions=('complete_pass', 'count'),
            adot=('air_yards', 'mean'),
            yac_per_rec=('yards_after_catch', 'mean')
        )
        .query('receptions >= 30')
    )

    # Median splits
    adot_med = style_stats['adot'].median()
    yac_med = style_stats['yac_per_rec'].median()

    def assign_style(row):
        if row['adot'] > adot_med and row['yac_per_rec'] > yac_med:
            return 'Elite'
        elif row['adot'] > adot_med:
            return 'Field Stretcher'
        elif row['yac_per_rec'] > yac_med:
            return 'YAC Specialist'
        else:
            return 'Possession'

    style_stats['style'] = style_stats.apply(assign_style, axis=1)

    return style_stats

8.6 Catch Rate and Expected Catch Rate

Raw Catch Rate Limitations

Catch rate (receptions / targets) is intuitive but flawed:

  1. Doesn't account for difficulty: Deep targets are harder than short
  2. QB influence: Bad throws lower catch rate
  3. Scheme effects: Some targets are contested by design

Catch Rate Over Expected

Advanced models estimate expected catch rate based on: - Target depth - Separation at target - Throw velocity and accuracy - Defensive coverage

def estimate_expected_catch_rate(pbp: pd.DataFrame) -> pd.DataFrame:
    """Estimate expected catch rate using available data."""
    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards'])

    # Simple expected catch rate model (using air yards as primary factor)
    # Real models use tracking data
    passes['expected_catch'] = np.where(
        passes['air_yards'] < 0, 0.80,
        np.where(passes['air_yards'] < 10, 0.70,
        np.where(passes['air_yards'] < 20, 0.50, 0.35))
    )

    # Calculate CPOE (receiver version)
    catch_analysis = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            receptions=('complete_pass', 'sum'),
            catch_rate=('complete_pass', 'mean'),
            expected_catch_rate=('expected_catch', 'mean'),
            adot=('air_yards', 'mean')
        )
        .query('targets >= 50')
    )

    catch_analysis['catch_rate_over_expected'] = (
        catch_analysis['catch_rate'] - catch_analysis['expected_catch_rate']
    )

    return catch_analysis.sort_values('catch_rate_over_expected', ascending=False)

Drop Rate

Drops are catches the receiver should have made but didn't:

def analyze_drops(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze drop rate (if data available)."""
    # Standard PBP doesn't have drops
    # Would need PFF or other charting data

    # Approximate with catchable but incomplete
    # This is imprecise without tracking data
    passes = pbp[pbp['pass_attempt'] == 1].copy()

    print("Note: True drop rate requires charting data (PFF)")
    print("Standard PBP doesn't distinguish drops from bad throws")

    return None

8.7 Separating Receiver from Quarterback

The Attribution Problem

A key challenge: how much credit does the receiver deserve vs. the QB?

Methods of Separation

1. Compare receivers with same QB:

def compare_receivers_same_qb(pbp: pd.DataFrame, qb_name: str) -> pd.DataFrame:
    """Compare all receivers targeted by the same QB."""
    qb_passes = pbp[(pbp['pass_attempt'] == 1) &
                    (pbp['passer_player_name'] == qb_name)]

    receiver_comparison = (qb_passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            epa=('epa', 'mean'),
            catch_rate=('complete_pass', 'mean'),
            yac=('yards_after_catch', 'mean')
        )
        .query('targets >= 20')
        .sort_values('epa', ascending=False)
    )

    return receiver_comparison

2. Compare same receiver with different QBs:

def receiver_across_qbs(pbp: pd.DataFrame, receiver_name: str) -> pd.DataFrame:
    """Analyze receiver with different quarterbacks."""
    rec_passes = pbp[(pbp['pass_attempt'] == 1) &
                     (pbp['receiver_player_name'] == receiver_name)]

    by_qb = (rec_passes
        .groupby('passer_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            epa=('epa', 'mean'),
            catch_rate=('complete_pass', 'mean')
        )
        .query('targets >= 10')
    )

    return by_qb

3. Adjust for QB quality:

def qb_adjusted_receiver_stats(pbp: pd.DataFrame) -> pd.DataFrame:
    """Adjust receiver stats for QB quality."""
    passes = pbp[pbp['pass_attempt'] == 1].copy()

    # Calculate QB baseline (EPA on all targets)
    qb_baseline = passes.groupby('passer_player_name')['epa'].mean()

    # Join QB baseline to each pass
    passes = passes.merge(
        qb_baseline.rename('qb_epa'),
        left_on='passer_player_name',
        right_index=True
    )

    # Calculate receiver EPA over QB baseline
    receiver_adj = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            raw_epa=('epa', 'mean'),
            qb_baseline=('qb_epa', 'mean'),
            epa_over_qb=('epa', lambda x: x.mean() - passes.loc[x.index, 'qb_epa'].mean())
        )
        .query('targets >= 50')
        .sort_values('epa_over_qb', ascending=False)
    )

    return receiver_adj

8.8 Situational Receiving Analysis

Third Down Performance

def third_down_receiving(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze third down receiving."""
    third_down = pbp[(pbp['pass_attempt'] == 1) & (pbp['down'] == 3)]

    third_stats = (third_down
        .groupby('receiver_player_name')
        .agg(
            third_targets=('pass_attempt', 'count'),
            third_receptions=('complete_pass', 'sum'),
            third_epa=('epa', 'mean'),
            conversion_rate=('first_down', 'mean')
        )
        .query('third_targets >= 15')
    )

    # Compare to overall
    all_passes = pbp[pbp['pass_attempt'] == 1]
    overall_epa = all_passes.groupby('receiver_player_name')['epa'].mean()

    third_stats['overall_epa'] = overall_epa
    third_stats['third_down_delta'] = third_stats['third_epa'] - third_stats['overall_epa']

    return third_stats.sort_values('third_epa', ascending=False)

Red Zone Receiving

def red_zone_receiving(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze red zone receiving."""
    red_zone = pbp[(pbp['pass_attempt'] == 1) & (pbp['yardline_100'] <= 20)]

    rz_stats = (red_zone
        .groupby('receiver_player_name')
        .agg(
            rz_targets=('pass_attempt', 'count'),
            rz_receptions=('complete_pass', 'sum'),
            rz_tds=('pass_touchdown', 'sum'),
            rz_epa=('epa', 'mean')
        )
        .query('rz_targets >= 10')
    )

    rz_stats['rz_td_rate'] = rz_stats['rz_tds'] / rz_stats['rz_targets']

    return rz_stats.sort_values('rz_td_rate', ascending=False)

Deep Ball Production

def deep_ball_receiving(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze deep ball production (20+ air yards)."""
    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards'])
    deep = passes[passes['air_yards'] >= 20]

    deep_stats = (deep
        .groupby('receiver_player_name')
        .agg(
            deep_targets=('pass_attempt', 'count'),
            deep_catches=('complete_pass', 'sum'),
            deep_yards=('yards_gained', 'sum'),
            deep_tds=('pass_touchdown', 'sum'),
            deep_epa=('epa', 'mean')
        )
        .query('deep_targets >= 10')
    )

    deep_stats['deep_catch_rate'] = deep_stats['deep_catches'] / deep_stats['deep_targets']
    deep_stats['yards_per_deep_target'] = deep_stats['deep_yards'] / deep_stats['deep_targets']

    return deep_stats.sort_values('deep_epa', ascending=False)

8.9 Position-Specific Analysis

Wide Receiver Tiers

def tier_wide_receivers(pbp: pd.DataFrame) -> pd.DataFrame:
    """Tier wide receivers by overall value."""
    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards', 'yards_after_catch'])

    wr_stats = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            yards=('yards_gained', 'sum'),
            tds=('pass_touchdown', 'sum'),
            epa_total=('epa', 'sum'),
            epa_per_target=('epa', 'mean'),
            adot=('air_yards', 'mean'),
            yac=('yards_after_catch', 'mean')
        )
        .query('targets >= 50')
    )

    # Calculate composite score
    wr_stats['epa_rank'] = wr_stats['epa_per_target'].rank(ascending=False)
    wr_stats['yards_rank'] = wr_stats['yards'].rank(ascending=False)
    wr_stats['composite'] = (wr_stats['epa_rank'] + wr_stats['yards_rank']) / 2

    # Assign tiers
    def assign_tier(rank, total):
        pct = rank / total
        if pct <= 0.10:
            return 'Elite'
        elif pct <= 0.25:
            return 'WR1'
        elif pct <= 0.50:
            return 'WR2'
        else:
            return 'WR3/Depth'

    n = len(wr_stats)
    wr_stats['tier'] = wr_stats['composite'].apply(lambda x: assign_tier(x, n))

    return wr_stats.sort_values('composite')

Tight End Evaluation

Tight ends require different evaluation due to blocking duties:

def tight_end_evaluation(pbp: pd.DataFrame, te_names: list = None) -> pd.DataFrame:
    """Evaluate tight ends (receiving only)."""
    passes = pbp[pbp['pass_attempt'] == 1]

    # Filter to known TEs if provided
    if te_names:
        passes = passes[passes['receiver_player_name'].isin(te_names)]

    te_stats = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            yards=('yards_gained', 'sum'),
            tds=('pass_touchdown', 'sum'),
            epa=('epa', 'mean'),
            catch_rate=('complete_pass', 'mean'),
            yac=('yards_after_catch', 'mean')
        )
        .query('targets >= 30')
    )

    # Note: doesn't capture blocking value
    print("Note: TE evaluation is incomplete without blocking data")

    return te_stats.sort_values('epa', ascending=False)

Slot vs. Outside Production

def slot_outside_comparison(pbp: pd.DataFrame) -> pd.DataFrame:
    """Compare slot vs outside receiving (if alignment data available)."""
    # Would need Next Gen Stats for alignment
    # Approximate using ADOT (slots typically lower)

    passes = pbp[pbp['pass_attempt'] == 1].dropna(subset=['air_yards'])

    profile = (passes
        .groupby('receiver_player_name')
        .agg(
            targets=('pass_attempt', 'count'),
            adot=('air_yards', 'mean'),
            yac=('yards_after_catch', 'mean'),
            epa=('epa', 'mean')
        )
        .query('targets >= 50')
    )

    # Approximate: Low ADOT + high YAC = likely slot
    profile['slot_profile'] = (profile['adot'] < 10) & (profile['yac'] > 4)

    return profile

8.10 Comprehensive Receiver Evaluation

Building a Receiver Evaluation Framework

class ReceiverEvaluator:
    """Comprehensive receiver evaluation framework."""

    def __init__(self, pbp: pd.DataFrame, min_targets: int = 50):
        self.pbp = pbp
        self.passes = pbp[pbp['pass_attempt'] == 1].copy()
        self.min_targets = min_targets
        self._all_stats = None

    @property
    def all_stats(self) -> pd.DataFrame:
        """Lazy-load comprehensive stats."""
        if self._all_stats is None:
            self._all_stats = self._calculate_all_stats()
        return self._all_stats

    def _calculate_all_stats(self) -> pd.DataFrame:
        """Calculate all receiver metrics."""
        passes = self.passes.dropna(subset=['air_yards', 'yards_after_catch'])

        stats = (passes
            .groupby('receiver_player_name')
            .agg(
                # Volume
                targets=('pass_attempt', 'count'),
                receptions=('complete_pass', 'sum'),
                yards=('yards_gained', 'sum'),
                tds=('pass_touchdown', 'sum'),

                # Efficiency
                epa_total=('epa', 'sum'),
                epa_per_target=('epa', 'mean'),
                success_rate=('epa', lambda x: (x > 0).mean()),

                # Catch metrics
                catch_rate=('complete_pass', 'mean'),

                # Depth and style
                adot=('air_yards', 'mean'),
                yac_per_rec=('yards_after_catch', lambda x:
                    x[passes.loc[x.index, 'complete_pass'] == 1].mean()),
                deep_targets=('air_yards', lambda x: (x >= 20).sum()),

                # Team context
                team=('posteam', lambda x: x.mode().iloc[0] if len(x) > 0 else None)
            )
            .query(f'targets >= {self.min_targets}')
        )

        # Add derived metrics
        stats['yards_per_target'] = stats['yards'] / stats['targets']
        stats['deep_target_pct'] = stats['deep_targets'] / stats['targets']

        # Rankings
        stats['epa_rank'] = stats['epa_per_target'].rank(ascending=False)

        return stats

    def evaluate(self, receiver_name: str) -> dict:
        """Generate detailed evaluation for a receiver."""
        rec_passes = self.passes[self.passes['receiver_player_name'] == receiver_name]

        if len(rec_passes) < 20:
            return {'error': 'Insufficient sample size'}

        basic = {
            'targets': len(rec_passes),
            'receptions': rec_passes['complete_pass'].sum(),
            'yards': rec_passes['yards_gained'].sum(),
            'tds': rec_passes['pass_touchdown'].sum(),
            'epa_total': rec_passes['epa'].sum(),
            'epa_per_target': rec_passes['epa'].mean(),
            'catch_rate': rec_passes['complete_pass'].mean(),
            'adot': rec_passes['air_yards'].mean(),
            'yac': rec_passes[rec_passes['complete_pass'] == 1]['yards_after_catch'].mean()
        }

        situational = {}

        # Third down
        third = rec_passes[rec_passes['down'] == 3]
        if len(third) >= 5:
            situational['third_down'] = {
                'targets': len(third),
                'epa': third['epa'].mean(),
                'conversion': third['first_down'].mean()
            }

        # Red zone
        rz = rec_passes[rec_passes['yardline_100'] <= 20]
        if len(rz) >= 5:
            situational['red_zone'] = {
                'targets': len(rz),
                'tds': rz['pass_touchdown'].sum(),
                'epa': rz['epa'].mean()
            }

        return {
            'name': receiver_name,
            'basic': basic,
            'situational': situational
        }

    def compare(self, receiver_names: list) -> pd.DataFrame:
        """Compare multiple receivers."""
        stats = self.all_stats
        return stats[stats.index.isin(receiver_names)]

    def generate_report(self, receiver_name: str) -> str:
        """Generate text evaluation report."""
        eval_data = self.evaluate(receiver_name)

        if 'error' in eval_data:
            return eval_data['error']

        basic = eval_data['basic']
        stats = self.all_stats

        if receiver_name in stats.index:
            rank = int(stats.loc[receiver_name, 'epa_rank'])
            n_receivers = len(stats)
        else:
            rank = 'N/A'
            n_receivers = 'N/A'

        report = f"""
========================================
RECEIVER EVALUATION: {receiver_name}
========================================

PRODUCTION:
  Targets: {basic['targets']}
  Receptions: {basic['receptions']}
  Yards: {basic['yards']}
  TDs: {basic['tds']}

EFFICIENCY:
  EPA/Target: {basic['epa_per_target']:.3f} (Rank: {rank}/{n_receivers})
  Total EPA: {basic['epa_total']:.1f}
  Catch Rate: {basic['catch_rate']*100:.1f}%

PLAYING STYLE:
  ADOT: {basic['adot']:.1f} yards
  YAC/Rec: {basic['yac']:.1f} yards

ASSESSMENT:
"""
        if basic['epa_per_target'] > 0.30:
            report += "  - Elite efficiency\n"
        if basic['adot'] > 12:
            report += "  - Deep threat\n"
        if basic['yac'] > 5:
            report += "  - YAC specialist\n"
        if basic['catch_rate'] > 0.70:
            report += "  - Reliable hands\n"

        return report

Chapter Summary

Key Takeaways

  1. Target share is foundational but must be paired with efficiency metrics
  2. EPA per target measures receiving value in context
  3. Air yards and YAC decompose receiving into components
  4. RACR shows how well receivers convert opportunities
  5. Catch rate needs context (expected catch rate adjusts for difficulty)
  6. QB influence makes attribution challenging but can be approximated
  7. Situational analysis (third down, red zone, deep) reveals role players

Common Analytical Mistakes

Mistake Better Approach
Total yards = value EPA per target matters more
High catch rate = elite Adjust for target difficulty
Low ADOT = bad Could be YAC specialist
Ignoring QB Control for passer quality

Looking Ahead

Chapter 9 examines offensive line analytics—the hardest position group to evaluate individually but critical to understanding offensive success.


Practice Exercises

See the accompanying exercises.md file for hands-on practice problems ranging from basic target share calculations to comprehensive receiver evaluation systems.

Further Reading

See further-reading.md for academic papers, industry resources, and data sources for advanced receiving analytics.