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...
In This Chapter
- Chapter Overview
- 8.1 The Challenge of Receiver Evaluation
- 8.2 Target-Based Metrics
- 8.3 EPA-Based Receiving Metrics
- 8.4 Air Yards and Receiving Depth
- 8.5 Yards After Catch (YAC)
- 8.6 Catch Rate and Expected Catch Rate
- 8.7 Separating Receiver from Quarterback
- 8.8 Situational Receiving Analysis
- 8.9 Position-Specific Analysis
- 8.10 Comprehensive Receiver Evaluation
- Chapter Summary
- Practice Exercises
- Further Reading
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:
- Calculate and interpret EPA-based receiving metrics
- Understand target share and its relationship to production
- Analyze air yards, intended air yards, and RACR
- Evaluate catch rate over expected (CPOE-receiver)
- Decompose receiving production into air yards vs. YAC
- Compare receivers accounting for quarterback quality
- 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:
- Quarterback dependence: The throw quality directly affects outcomes
- Scheme effects: Some receivers get open by design
- Target distribution: Volume is determined by others
- 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:
- Doesn't account for difficulty: Deep targets are harder than short
- QB influence: Bad throws lower catch rate
- 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
- Target share is foundational but must be paired with efficiency metrics
- EPA per target measures receiving value in context
- Air yards and YAC decompose receiving into components
- RACR shows how well receivers convert opportunities
- Catch rate needs context (expected catch rate adjusts for difficulty)
- QB influence makes attribution challenging but can be approximated
- 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.