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

  1. Build comprehensive player evaluation frameworks across all positions
  2. Isolate individual performance from team context and supporting cast
  3. Calculate position-specific metrics that capture unique responsibilities
  4. Adjust performance metrics for opponent strength and game situation
  5. Project future player performance using age curves and predictive models

Introduction

The most consequential decisions in football involve people, not plays. Should you draft the quarterback with the cannon arm or the one who protects the ball? Is your aging running back still productive, or has decline begun? Which free agent wide receiver will thrive in your offense? These personnel decisions—draft selections, free agent signings, contract extensions, trades—shape franchises for years.

Yet evaluating individual players is remarkably difficult. Football is the ultimate team sport, where 22 players interact on every play. A quarterback's completion percentage depends heavily on his receivers, offensive line, play-calling, and defensive coverage. A running back's yards per carry reflects blocking quality as much as talent. A cornerback's statistics depend on whether offenses avoid throwing his direction.

This chapter tackles the challenge of player evaluation systematically. We'll develop methods to isolate individual contributions from team context, create position-specific metrics that capture what truly matters, adjust for opponent quality and situation, account for aging and injury risk, and build predictive models for future performance.

What Makes Player Evaluation Difficult?

Three fundamental challenges complicate player evaluation: 1. **Interdependence**: Every player's performance depends on teammates 2. **Sample Size**: Individual players have limited opportunities (especially skill position players) 3. **Context Dependence**: Performance varies dramatically with scheme, opponents, and situation Effective evaluation requires methods that address all three challenges.

The Challenge of Isolating Individual Performance

The Attribution Problem

Consider a 15-yard completion. Who deserves credit?

  • The quarterback for an accurate throw?
  • The receiver for winning his route and catching the ball?
  • The offensive line for providing protection?
  • The play designer for scheming the receiver open?
  • The running back for forcing the defense into base defense?

All contributed, but traditional box score statistics assign all 15 yards and the completion to the quarterback and receiver. This attribution problem pervades football analytics.

Decomposing Play Value

Modern player evaluation decomposes play outcomes into components:

$$ \text{Play Value} = \text{Individual Skill} + \text{Teammate Quality} + \text{Opponent Quality} + \text{Scheme} + \text{Situation} + \text{Luck} $$

Our goal is isolating the Individual Skill component while controlling for everything else.

Approaches to Isolation

1. Peer Comparisons: Compare players in similar contexts

  • Same team (controls for scheme, teammates)
  • Same position (controls for role)
  • Same situations (controls for game state)

2. Regression Adjustment: Statistically control for context

  • Include team quality, opponent quality, situation in models
  • Estimate player coefficients after accounting for context

3. Counterfactual Analysis: What would happen with replacement player?

  • Value over replacement (e.g., WAR concepts)
  • Compare to league-average performance in same situations

4. Tracking Data: Measure pre-outcome performance

  • Separation before catch (receivers)
  • Pressure rate (pass rushers)
  • Coverage quality regardless of throw (defensive backs)

Quarterback Evaluation

Quarterbacks have the most data and the richest evaluation frameworks.

Core QB Metrics

Expected Points Added (EPA)

EPA per play captures overall QB value:

$$ \text{QB EPA/Play} = \frac{\sum \text{EPA on QB dropbacks}}{\text{Total dropbacks}} $$

Strengths:
- Accounts for down, distance, field position
- Correlates strongly with winning
- Available for every play

Limitations:
- Includes receiver, O-line, play-calling contributions
- Sensitive to opponent quality
- Doesn't separate decision-making from execution

Completion Percentage Over Expected (CPOE)

CPOE isolates QB accuracy from pass difficulty:

$$ \text{CPOE} = \text{Actual Completion \%} - \text{Expected Completion \%} $$

Expected completion percentage is modeled from:
- Air yards (distance of throw)
- Pass location (sideline vs middle)
- Pressure (clean pocket vs rushed)
- Receiver separation

Example: A QB completing 67% of passes when expected to complete 63% has +4% CPOE.

Success Rate

Percentage of dropbacks generating positive EPA:

$$ \text{QB Success Rate} = \frac{\text{Dropbacks with EPA > 0}}{\text{Total dropbacks}} $$

Complements EPA by measuring consistency rather than magnitude.

Pressure-Adjusted Metrics

QB performance varies dramatically under pressure:

#| label: qb-pressure-metrics
#| message: false
#| warning: false

library(tidyverse)
library(nflfastR)
library(gt)

# Load 2023 play-by-play data
pbp <- load_pbp(2023)

# Calculate QB metrics by pressure situation
qb_pressure <- pbp %>%
  filter(season_type == "REG", !is.na(qb_hit), !is.na(epa)) %>%
  mutate(pressure = if_else(qb_hit == 1 | qb_hurry == 1, "Pressured", "Clean")) %>%
  group_by(passer, pressure) %>%
  summarise(
    dropbacks = n(),
    epa_per_play = mean(epa, na.rm = TRUE),
    cpoe = mean(cpoe, na.rm = TRUE),
    success_rate = mean(epa > 0, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(dropbacks >= 50) %>%
  arrange(pressure, desc(epa_per_play))

# Display top QBs under pressure
qb_pressure %>%
  filter(pressure == "Pressured") %>%
  head(10) %>%
  gt() %>%
  cols_label(
    passer = "Quarterback",
    pressure = "Situation",
    dropbacks = "Dropbacks",
    epa_per_play = "EPA/Play",
    cpoe = "CPOE",
    success_rate = "Success Rate"
  ) %>%
  fmt_number(
    columns = c(epa_per_play, cpoe),
    decimals = 3
  ) %>%
  fmt_percent(
    columns = success_rate,
    decimals = 1
  ) %>%
  tab_header(
    title = "Top Quarterbacks Under Pressure",
    subtitle = "2023 Season (Min. 50 pressured dropbacks)"
  )
#| label: qb-pressure-metrics-py
#| message: false
#| warning: false

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

# Load 2023 play-by-play data
pbp = nfl.import_pbp_data([2023])

# Calculate QB metrics by pressure situation
pbp_filtered = pbp[(pbp['season_type'] == 'REG') &
                    (pbp['qb_hit'].notna()) &
                    (pbp['epa'].notna())].copy()

pbp_filtered['pressure'] = np.where(
    (pbp_filtered['qb_hit'] == 1) | (pbp_filtered['qb_hurry'] == 1),
    'Pressured', 'Clean'
)

qb_pressure = (pbp_filtered
    .groupby(['passer', 'pressure'])
    .agg(
        dropbacks=('epa', 'count'),
        epa_per_play=('epa', 'mean'),
        cpoe=('cpoe', 'mean'),
        success_rate=('epa', lambda x: (x > 0).mean())
    )
    .reset_index()
    .query('dropbacks >= 50')
    .sort_values(['pressure', 'epa_per_play'], ascending=[True, False])
)

# Display top QBs under pressure
print("\nTop Quarterbacks Under Pressure (2023 Season)")
print("=" * 70)
print(qb_pressure[qb_pressure['pressure'] == 'Pressured']
      .head(10)
      .to_string(index=False))

Time to Throw and Aggressiveness

Time to Throw: Average seconds from snap to release

  • Fast release (< 2.4s): Quick game, pressure avoidance
  • Slow release (> 2.8s): Deep shots, holding ball

Air Yards per Attempt: Average depth of target

  • Measures aggression and downfield attacking
  • Typically 7-9 yards; higher indicates aggressive approach

Intended Air Yards: Includes incompletions, measures true aggressiveness

Comprehensive QB Evaluation Framework

Combine multiple metrics for holistic evaluation:

#| label: comprehensive-qb-eval
#| message: false
#| warning: false
#| cache: true

# Comprehensive QB evaluation
qb_eval <- pbp %>%
  filter(season_type == "REG", !is.na(epa), !is.na(cpoe)) %>%
  group_by(passer, posteam) %>%
  summarise(
    dropbacks = n(),
    # Overall efficiency
    epa_per_play = mean(epa, na.rm = TRUE),
    success_rate = mean(epa > 0, na.rm = TRUE),
    # Accuracy
    cpoe = mean(cpoe, na.rm = TRUE),
    completion_pct = mean(complete_pass, na.rm = TRUE),
    # Aggression
    avg_air_yards = mean(air_yards, na.rm = TRUE),
    deep_rate = mean(air_yards >= 20, na.rm = TRUE),
    # Turnovers
    int_rate = sum(interception, na.rm = TRUE) / n(),
    sack_rate = sum(sack, na.rm = TRUE) / n(),
    # Big plays
    explosive_rate = mean(epa > 1, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(dropbacks >= 200) %>%
  arrange(desc(epa_per_play))

# Display top QBs
qb_eval %>%
  head(10) %>%
  gt() %>%
  cols_label(
    passer = "QB",
    posteam = "Team",
    dropbacks = "Plays",
    epa_per_play = "EPA/Play",
    success_rate = "Success%",
    cpoe = "CPOE",
    completion_pct = "Comp%",
    avg_air_yards = "aDOT",
    deep_rate = "Deep%",
    int_rate = "INT%",
    sack_rate = "Sack%",
    explosive_rate = "Explosive%"
  ) %>%
  fmt_number(
    columns = c(epa_per_play, cpoe, avg_air_yards),
    decimals = 2
  ) %>%
  fmt_percent(
    columns = c(success_rate, completion_pct, deep_rate,
                int_rate, sack_rate, explosive_rate),
    decimals = 1
  ) %>%
  tab_header(
    title = "Comprehensive QB Evaluation",
    subtitle = "2023 Season (Min. 200 dropbacks)"
  ) %>%
  tab_spanner(
    label = "Efficiency",
    columns = c(epa_per_play, success_rate)
  ) %>%
  tab_spanner(
    label = "Accuracy",
    columns = c(cpoe, completion_pct)
  ) %>%
  tab_spanner(
    label = "Style",
    columns = c(avg_air_yards, deep_rate)
  ) %>%
  tab_spanner(
    label = "Negatives",
    columns = c(int_rate, sack_rate)
  )
#| label: comprehensive-qb-eval-py
#| message: false
#| warning: false
#| cache: true

# Comprehensive QB evaluation
qb_eval = (pbp
    .query("season_type == 'REG' & epa.notna() & cpoe.notna()")
    .groupby(['passer', 'posteam'])
    .agg(
        dropbacks=('epa', 'count'),
        epa_per_play=('epa', 'mean'),
        success_rate=('epa', lambda x: (x > 0).mean()),
        cpoe=('cpoe', 'mean'),
        completion_pct=('complete_pass', 'mean'),
        avg_air_yards=('air_yards', 'mean'),
        deep_rate=('air_yards', lambda x: (x >= 20).mean()),
        int_rate=('interception', lambda x: x.sum() / len(x)),
        sack_rate=('sack', lambda x: x.sum() / len(x)),
        explosive_rate=('epa', lambda x: (x > 1).mean())
    )
    .reset_index()
    .query('dropbacks >= 200')
    .sort_values('epa_per_play', ascending=False)
)

print("\nComprehensive QB Evaluation (2023 Season)")
print("=" * 120)
print(qb_eval.head(10).to_string(index=False))

Visualizing QB Performance

EPA and CPOE together provide a powerful two-dimensional view:

#| label: fig-qb-epa-cpoe
#| fig-cap: "QB Performance: EPA per Play vs CPOE (2023 Season)"
#| fig-width: 10
#| fig-height: 8
#| message: false
#| warning: false

library(nflplotR)

# Prepare data for plot
qb_plot <- pbp %>%
  filter(season_type == "REG", !is.na(epa), !is.na(cpoe)) %>%
  group_by(passer, posteam) %>%
  summarise(
    dropbacks = n(),
    epa_per_play = mean(epa, na.rm = TRUE),
    cpoe = mean(cpoe, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(dropbacks >= 200)

# Create plot
ggplot(qb_plot, aes(x = cpoe, y = epa_per_play)) +
  geom_hline(yintercept = mean(qb_plot$epa_per_play),
             linetype = "dashed", alpha = 0.5) +
  geom_vline(xintercept = mean(qb_plot$cpoe),
             linetype = "dashed", alpha = 0.5) +
  geom_point(aes(size = dropbacks), alpha = 0.6, color = "#013369") +
  geom_text(aes(label = passer), size = 3, vjust = -0.8, hjust = 0.5) +
  scale_size_continuous(range = c(3, 10), name = "Dropbacks") +
  labs(
    title = "Quarterback Performance: Efficiency vs Accuracy",
    subtitle = "EPA per Play vs Completion Percentage Over Expected | 2023 Season",
    x = "Completion Percentage Over Expected (CPOE)",
    y = "Expected Points Added per Play",
    caption = "Data: nflfastR | Min. 200 dropbacks"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(size = 11),
    legend.position = "right",
    panel.grid.minor = element_blank()
  )
#| label: fig-qb-epa-cpoe-py
#| fig-cap: "QB Performance: EPA per Play vs CPOE - Python (2023)"
#| fig-width: 10
#| fig-height: 8
#| message: false
#| warning: false

import matplotlib.pyplot as plt

# Prepare data
qb_plot = (pbp
    .query("season_type == 'REG' & epa.notna() & cpoe.notna()")
    .groupby(['passer', 'posteam'])
    .agg(
        dropbacks=('epa', 'count'),
        epa_per_play=('epa', 'mean'),
        cpoe=('cpoe', 'mean')
    )
    .reset_index()
    .query('dropbacks >= 200')
)

# Create plot
fig, ax = plt.subplots(figsize=(10, 8))

scatter = ax.scatter(qb_plot['cpoe'], qb_plot['epa_per_play'],
                     s=qb_plot['dropbacks'], alpha=0.6, c='#013369')

# Add QB names
for idx, row in qb_plot.iterrows():
    ax.text(row['cpoe'], row['epa_per_play'], row['passer'],
            fontsize=8, ha='center', va='bottom')

# Add average lines
ax.axhline(y=qb_plot['epa_per_play'].mean(),
           color='gray', linestyle='--', alpha=0.5)
ax.axvline(x=qb_plot['cpoe'].mean(),
           color='gray', linestyle='--', alpha=0.5)

ax.set_xlabel('Completion Percentage Over Expected (CPOE)', fontsize=12)
ax.set_ylabel('Expected Points Added per Play', fontsize=12)
ax.set_title('Quarterback Performance: Efficiency vs Accuracy\n' +
             'EPA per Play vs CPOE | 2023 Season',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.99, 0.01, 'Data: nfl_data_py | Min. 200 dropbacks',
        transform=ax.transAxes, ha='right', va='bottom',
        fontsize=8, style='italic')

# Add legend for size
handles, labels = scatter.legend_elements(prop="sizes", alpha=0.6, num=4)
legend = ax.legend(handles, labels, loc="upper left", title="Dropbacks")

plt.tight_layout()
plt.show()

📊 Visualization Output

The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.

Running Back Evaluation

Running backs contribute as rushers and receivers, requiring dual-threat evaluation.

Rushing Efficiency Metrics

EPA per Rush

Basic rushing value metric:

$$ \text{RB EPA/Rush} = \frac{\sum \text{EPA on rushes}}{\text{Total rushes}} $$

Success Rate

Percentage of rushes generating positive EPA:

$$ \text{RB Success Rate} = \frac{\text{Rushes with EPA > 0}}{\text{Total rushes}} $$

Yards Over Expected (YOE)

Compares actual yards to expected based on:
- Down and distance
- Field position
- Defensive front
- Blocking quality

Receiving Value

Many elite RBs generate more value receiving than rushing:

#| label: rb-dual-threat
#| message: false
#| warning: false

# RB rushing and receiving value
rb_eval <- pbp %>%
  filter(season_type == "REG") %>%
  # Rushing
  group_by(rusher) %>%
  summarise(
    rushes = sum(!is.na(rushing_yards)),
    rush_epa_per = mean(epa[!is.na(rushing_yards)], na.rm = TRUE),
    rush_success = mean(epa[!is.na(rushing_yards)] > 0, na.rm = TRUE),
    rush_explosive = mean(epa[!is.na(rushing_yards)] > 1, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  rename(player = rusher) %>%
  # Join with receiving
  full_join(
    pbp %>%
      filter(season_type == "REG") %>%
      group_by(receiver) %>%
      summarise(
        targets = sum(!is.na(air_yards)),
        receptions = sum(complete_pass, na.rm = TRUE),
        rec_epa_per = mean(epa[!is.na(air_yards)], na.rm = TRUE),
        rec_success = mean(epa[!is.na(air_yards)] > 0, na.rm = TRUE),
        .groups = "drop"
      ) %>%
      rename(player = receiver),
    by = "player"
  ) %>%
  mutate(
    rushes = replace_na(rushes, 0),
    targets = replace_na(targets, 0),
    total_touches = rushes + targets,
    # Combined EPA
    combined_epa = (rush_epa_per * rushes + rec_epa_per * targets) / total_touches
  ) %>%
  filter(rushes >= 100, total_touches >= 150) %>%
  arrange(desc(combined_epa))

# Display top dual-threat RBs
rb_eval %>%
  head(15) %>%
  gt() %>%
  cols_label(
    player = "Running Back",
    rushes = "Rushes",
    rush_epa_per = "Rush EPA",
    rush_success = "Rush SR",
    targets = "Targets",
    receptions = "Rec",
    rec_epa_per = "Rec EPA",
    rec_success = "Rec SR",
    combined_epa = "Combined EPA"
  ) %>%
  fmt_number(
    columns = c(rush_epa_per, rec_epa_per, combined_epa),
    decimals = 3
  ) %>%
  fmt_percent(
    columns = c(rush_success, rec_success),
    decimals = 1
  ) %>%
  tab_header(
    title = "Dual-Threat Running Back Evaluation",
    subtitle = "2023 Season (Min. 100 rushes, 150 total touches)"
  ) %>%
  tab_spanner(
    label = "Rushing",
    columns = c(rushes, rush_epa_per, rush_success)
  ) %>%
  tab_spanner(
    label = "Receiving",
    columns = c(targets, receptions, rec_epa_per, rec_success)
  )
#| label: rb-dual-threat-py
#| message: false
#| warning: false

# RB rushing value
rush_stats = (pbp
    .query("season_type == 'REG' & rushing_yards.notna()")
    .groupby('rusher')
    .agg(
        rushes=('rushing_yards', 'count'),
        rush_epa_per=('epa', 'mean'),
        rush_success=('epa', lambda x: (x > 0).mean()),
        rush_explosive=('epa', lambda x: (x > 1).mean())
    )
    .reset_index()
    .rename(columns={'rusher': 'player'})
)

# RB receiving value
rec_stats = (pbp
    .query("season_type == 'REG' & air_yards.notna()")
    .groupby('receiver')
    .agg(
        targets=('air_yards', 'count'),
        receptions=('complete_pass', 'sum'),
        rec_epa_per=('epa', 'mean'),
        rec_success=('epa', lambda x: (x > 0).mean())
    )
    .reset_index()
    .rename(columns={'receiver': 'player'})
)

# Combine
rb_eval = (rush_stats
    .merge(rec_stats, on='player', how='outer')
    .fillna({'rushes': 0, 'targets': 0})
)

rb_eval['total_touches'] = rb_eval['rushes'] + rb_eval['targets']
rb_eval['combined_epa'] = (
    (rb_eval['rush_epa_per'] * rb_eval['rushes'] +
     rb_eval['rec_epa_per'] * rb_eval['targets']) / rb_eval['total_touches']
)

# Filter and sort
rb_eval = (rb_eval
    .query('rushes >= 100 & total_touches >= 150')
    .sort_values('combined_epa', ascending=False)
)

print("\nDual-Threat Running Back Evaluation (2023 Season)")
print("=" * 100)
print(rb_eval.head(15).to_string(index=False))

Blocking Value

RB blocking is crucial but difficult to quantify:

  • Pass protection snaps: Frequency of blitz pickup assignments
  • Pressure allowed: Rate of QB pressures when blocking
  • Run blocking: Contribution on plays without the ball

Tracking data enables better blocking evaluation, but public data remains limited.

Wide Receiver and Tight End Evaluation

Route Running and Separation

Elite receivers create separation before the catch:

Average Separation: Distance from nearest defender at catch point

  • Measures route running quality
  • Typical range: 2.5-4.0 yards
  • Higher separation = easier throws for QB

Open Percentage: Rate of targets with >3 yards separation

Target Quality vs Efficiency

#| label: wr-evaluation
#| message: false
#| warning: false

# WR/TE comprehensive evaluation
wr_eval <- pbp %>%
  filter(season_type == "REG", !is.na(air_yards)) %>%
  group_by(receiver, posteam) %>%
  summarise(
    targets = n(),
    receptions = sum(complete_pass, na.rm = TRUE),
    # Efficiency
    epa_per_target = mean(epa, na.rm = TRUE),
    success_rate = mean(epa > 0, na.rm = TRUE),
    yards_per_route = sum(receiving_yards, na.rm = TRUE) / targets,
    # Target quality
    avg_depth = mean(air_yards, na.rm = TRUE),
    deep_target_rate = mean(air_yards >= 20, na.rm = TRUE),
    # After catch
    yac_per_rec = mean(yards_after_catch[complete_pass == 1], na.rm = TRUE),
    # Explosiveness
    explosive_rate = mean(epa > 1, na.rm = TRUE),
    td_rate = sum(touchdown, na.rm = TRUE) / targets,
    .groups = "drop"
  ) %>%
  filter(targets >= 50) %>%
  arrange(desc(epa_per_target))

# Display top receivers
wr_eval %>%
  head(15) %>%
  gt() %>%
  cols_label(
    receiver = "Receiver",
    posteam = "Team",
    targets = "Tgts",
    receptions = "Rec",
    epa_per_target = "EPA/Tgt",
    success_rate = "Success%",
    yards_per_route = "Yds/Rt",
    avg_depth = "aDOT",
    deep_target_rate = "Deep%",
    yac_per_rec = "YAC/Rec",
    explosive_rate = "Expl%",
    td_rate = "TD%"
  ) %>%
  fmt_number(
    columns = c(epa_per_target, yards_per_route, avg_depth, yac_per_rec),
    decimals = 2
  ) %>%
  fmt_percent(
    columns = c(success_rate, deep_target_rate, explosive_rate, td_rate),
    decimals = 1
  ) %>%
  tab_header(
    title = "Wide Receiver & Tight End Evaluation",
    subtitle = "2023 Season (Min. 50 targets)"
  ) %>%
  tab_spanner(
    label = "Efficiency",
    columns = c(epa_per_target, success_rate, yards_per_route)
  ) %>%
  tab_spanner(
    label = "Usage",
    columns = c(avg_depth, deep_target_rate)
  ) %>%
  tab_spanner(
    label = "Big Plays",
    columns = c(yac_per_rec, explosive_rate, td_rate)
  )
#| label: wr-evaluation-py
#| message: false
#| warning: false

# WR/TE comprehensive evaluation
wr_eval = (pbp
    .query("season_type == 'REG' & air_yards.notna()")
    .groupby(['receiver', 'posteam'])
    .agg(
        targets=('air_yards', 'count'),
        receptions=('complete_pass', 'sum'),
        epa_per_target=('epa', 'mean'),
        success_rate=('epa', lambda x: (x > 0).mean()),
        total_yards=('receiving_yards', 'sum'),
        avg_depth=('air_yards', 'mean'),
        deep_target_rate=('air_yards', lambda x: (x >= 20).mean()),
        yac_per_rec=('yards_after_catch', lambda x: x[pbp.loc[x.index, 'complete_pass'] == 1].mean()),
        explosive_rate=('epa', lambda x: (x > 1).mean()),
        td_rate=('touchdown', lambda x: x.sum() / len(x))
    )
    .reset_index()
)

wr_eval['yards_per_route'] = wr_eval['total_yards'] / wr_eval['targets']

# Filter and sort
wr_eval = (wr_eval
    .query('targets >= 50')
    .sort_values('epa_per_target', ascending=False)
)

print("\nWide Receiver & Tight End Evaluation (2023 Season)")
print("=" * 120)
print(wr_eval.head(15)[['receiver', 'posteam', 'targets', 'receptions',
                         'epa_per_target', 'success_rate', 'yards_per_route',
                         'avg_depth', 'yac_per_rec']].to_string(index=False))

Separation vs Yards After Catch

Different receiver archetypes excel in different ways:

#| label: fig-wr-separation-yac
#| fig-cap: "WR Archetypes: Route Running vs YAC Ability (2023)"
#| fig-width: 10
#| fig-height: 8
#| message: false
#| warning: false
#| eval: false

# Note: This requires Next Gen Stats data for separation
# Simulating with available metrics

wr_plot <- pbp %>%
  filter(season_type == "REG", !is.na(air_yards), complete_pass == 1) %>%
  group_by(receiver) %>%
  summarise(
    receptions = n(),
    avg_air_yards = mean(air_yards, na.rm = TRUE),
    yac_per_rec = mean(yards_after_catch, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(receptions >= 40)

ggplot(wr_plot, aes(x = avg_air_yards, y = yac_per_rec)) +
  geom_point(aes(size = receptions), alpha = 0.6, color = "#69BE28") +
  geom_text(aes(label = receiver), size = 3, vjust = -0.8) +
  geom_hline(yintercept = mean(wr_plot$yac_per_rec),
             linetype = "dashed", alpha = 0.5) +
  geom_vline(xintercept = mean(wr_plot$avg_air_yards),
             linetype = "dashed", alpha = 0.5) +
  scale_size_continuous(range = c(3, 10), name = "Receptions") +
  labs(
    title = "Receiver Archetypes: Route Depth vs YAC",
    subtitle = "Average Depth of Target vs Yards After Catch per Reception | 2023",
    x = "Average Depth of Target (yards)",
    y = "Yards After Catch per Reception",
    caption = "Data: nflfastR | Min. 40 receptions"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 14),
    legend.position = "right"
  )

📊 Visualization Output

The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.

#| label: fig-wr-separation-yac-py
#| fig-cap: "WR Archetypes: Route Running vs YAC - Python (2023)"
#| fig-width: 10
#| fig-height: 8
#| message: false
#| warning: false
#| eval: false

# Prepare data
wr_plot = (pbp
    .query("season_type == 'REG' & air_yards.notna() & complete_pass == 1")
    .groupby('receiver')
    .agg(
        receptions=('complete_pass', 'count'),
        avg_air_yards=('air_yards', 'mean'),
        yac_per_rec=('yards_after_catch', 'mean')
    )
    .reset_index()
    .query('receptions >= 40')
)

# Create plot
fig, ax = plt.subplots(figsize=(10, 8))

scatter = ax.scatter(wr_plot['avg_air_yards'], wr_plot['yac_per_rec'],
                     s=wr_plot['receptions']*3, alpha=0.6, c='#69BE28')

for idx, row in wr_plot.iterrows():
    ax.text(row['avg_air_yards'], row['yac_per_rec'], row['receiver'],
            fontsize=8, ha='center', va='bottom')

ax.axhline(y=wr_plot['yac_per_rec'].mean(),
           color='gray', linestyle='--', alpha=0.5)
ax.axvline(x=wr_plot['avg_air_yards'].mean(),
           color='gray', linestyle='--', alpha=0.5)

ax.set_xlabel('Average Depth of Target (yards)', fontsize=12)
ax.set_ylabel('Yards After Catch per Reception', fontsize=12)
ax.set_title('Receiver Archetypes: Route Depth vs YAC\n2023 Season',
             fontsize=14, fontweight='bold', pad=20)

plt.tight_layout()
plt.show()

Offensive Line Evaluation

O-line evaluation is particularly challenging due to:
- Collective nature of blocking
- Limited public data
- Difficulty attributing outcomes

Pass Protection Metrics

Sack Rate Allowed: Sacks per pass block snap

Pressure Rate Allowed: Pressures per pass block snap

Pass Block Win Rate: % of snaps maintaining block >2.5 seconds (ESPN metric)

Run Blocking Metrics

Run Block Win Rate: % of snaps driving defender off line

Yards Before Contact: RB yards before first defensive contact

Power Success Rate: Success on short-yardage runs

Position-Specific Context

#| label: oline-team-analysis
#| message: false
#| warning: false

# Team-level O-line performance (proxy for individual evaluation)
oline_performance <- pbp %>%
  filter(season_type == "REG", !is.na(epa)) %>%
  group_by(posteam) %>%
  summarise(
    # Pass protection
    pass_plays = sum(pass == 1, na.rm = TRUE),
    sack_rate = sum(sack, na.rm = TRUE) / pass_plays,
    pressure_rate = sum(qb_hit == 1 | qb_hurry == 1, na.rm = TRUE) / pass_plays,
    qb_epa_clean = mean(epa[pass == 1 & qb_hit == 0 & qb_hurry == 0], na.rm = TRUE),
    # Run blocking
    run_plays = sum(rush == 1, na.rm = TRUE),
    rush_epa_per = mean(epa[rush == 1], na.rm = TRUE),
    rush_success = mean(epa[rush == 1] > 0, na.rm = TRUE),
    stuffed_rate = sum(rush == 1 & yards_gained <= 0, na.rm = TRUE) / run_plays,
    .groups = "drop"
  ) %>%
  arrange(sack_rate)

oline_performance %>%
  head(10) %>%
  gt() %>%
  cols_label(
    posteam = "Team",
    pass_plays = "Pass Plays",
    sack_rate = "Sack%",
    pressure_rate = "Pressure%",
    qb_epa_clean = "EPA (Clean)",
    run_plays = "Rush Plays",
    rush_epa_per = "Rush EPA",
    rush_success = "Rush SR",
    stuffed_rate = "Stuff%"
  ) %>%
  fmt_percent(
    columns = c(sack_rate, pressure_rate, rush_success, stuffed_rate),
    decimals = 1
  ) %>%
  fmt_number(
    columns = c(qb_epa_clean, rush_epa_per),
    decimals = 3
  ) %>%
  tab_header(
    title = "Offensive Line Performance by Team",
    subtitle = "2023 Season"
  ) %>%
  tab_spanner(
    label = "Pass Protection",
    columns = c(pass_plays, sack_rate, pressure_rate, qb_epa_clean)
  ) %>%
  tab_spanner(
    label = "Run Blocking",
    columns = c(run_plays, rush_epa_per, rush_success, stuffed_rate)
  )
#| label: oline-team-analysis-py
#| message: false
#| warning: false

# Calculate pass plays and metrics
pass_stats = (pbp
    .query("season_type == 'REG' & epa.notna() & pass == 1")
    .groupby('posteam')
    .agg(
        pass_plays=('pass', 'count'),
        sack_rate=('sack', lambda x: x.sum() / len(x)),
        pressure_rate=('qb_hit', lambda x: ((x == 1) |
                      (pbp.loc[x.index, 'qb_hurry'] == 1)).sum() / len(x))
    )
)

# Calculate clean pocket EPA
clean_epa = (pbp
    .query("season_type == 'REG' & pass == 1 & qb_hit == 0 & qb_hurry == 0")
    .groupby('posteam')['epa']
    .mean()
    .rename('qb_epa_clean')
)

# Run blocking stats
run_stats = (pbp
    .query("season_type == 'REG' & rush == 1")
    .groupby('posteam')
    .agg(
        run_plays=('rush', 'count'),
        rush_epa_per=('epa', 'mean'),
        rush_success=('epa', lambda x: (x > 0).mean()),
        stuffed_rate=('yards_gained', lambda x: (x <= 0).sum() / len(x))
    )
)

# Combine
oline_performance = (pass_stats
    .join(clean_epa)
    .join(run_stats)
    .reset_index()
    .sort_values('sack_rate')
)

print("\nOffensive Line Performance by Team (2023 Season)")
print("=" * 100)
print(oline_performance.head(10).to_string(index=False))

Defensive Player Evaluation

Defensive Line Metrics

Pass Rush:
- Pressure rate
- Sack rate
- QB hits and hurries
- Pass rush win rate

Run Defense:
- Run stop rate (tackle at/behind LOS)
- Tackle rate
- Missed tackle rate

Linebacker Metrics

Linebackers have diverse responsibilities:

  • Coverage snaps and targets allowed
  • Run defense participation
  • Blitz effectiveness
  • Tackling efficiency

Defensive Back Metrics

#| label: db-evaluation
#| message: false
#| warning: false
#| eval: false

# Note: Requires player-level coverage data
# This example shows team-level defensive metrics

db_team_metrics <- pbp %>%
  filter(season_type == "REG", pass == 1, !is.na(epa)) %>%
  group_by(defteam) %>%
  summarise(
    pass_attempts = n(),
    completion_pct = mean(complete_pass, na.rm = TRUE),
    yards_per_att = mean(yards_gained, na.rm = TRUE),
    epa_allowed = mean(epa, na.rm = TRUE),
    success_rate_allowed = mean(epa > 0, na.rm = TRUE),
    int_rate = sum(interception, na.rm = TRUE) / n(),
    deep_completion_pct = mean(complete_pass[air_yards >= 20], na.rm = TRUE),
    .groups = "drop"
  ) %>%
  arrange(epa_allowed)

db_team_metrics %>%
  head(10) %>%
  gt() %>%
  cols_label(
    defteam = "Team",
    pass_attempts = "Attempts",
    completion_pct = "Comp%",
    yards_per_att = "Yds/Att",
    epa_allowed = "EPA Allowed",
    success_rate_allowed = "SR Allowed",
    int_rate = "INT%",
    deep_completion_pct = "Deep Comp%"
  ) %>%
  fmt_percent(
    columns = c(completion_pct, success_rate_allowed, int_rate, deep_completion_pct),
    decimals = 1
  ) %>%
  fmt_number(
    columns = c(yards_per_att, epa_allowed),
    decimals = 2
  ) %>%
  tab_header(
    title = "Pass Defense Performance",
    subtitle = "2023 Season (Team Level)"
  )
#| label: db-evaluation-py
#| message: false
#| warning: false
#| eval: false

# Team-level pass defense metrics
db_team_metrics = (pbp
    .query("season_type == 'REG' & pass == 1 & epa.notna()")
    .groupby('defteam')
    .agg(
        pass_attempts=('pass', 'count'),
        completion_pct=('complete_pass', 'mean'),
        yards_per_att=('yards_gained', 'mean'),
        epa_allowed=('epa', 'mean'),
        success_rate_allowed=('epa', lambda x: (x > 0).mean()),
        int_rate=('interception', lambda x: x.sum() / len(x))
    )
    .reset_index()
    .sort_values('epa_allowed')
)

print("\nPass Defense Performance (2023 Season)")
print("=" * 90)
print(db_team_metrics.head(10).to_string(index=False))

Coverage vs Pass Rush

Defensive performance results from both coverage and pressure:

$$ \text{Pass Defense EPA} = f(\text{Coverage Quality}, \text{Pass Rush}, \text{Scheme}) $$

Separating coverage from pressure requires tracking data on:
- Time to throw
- Separation at throw
- Pressure rate

Age Curves and Player Decline

Understanding aging patterns is crucial for contract and roster decisions.

Position-Specific Age Curves

Different positions peak and decline at different ages:

#| label: age-curve-analysis
#| message: false
#| warning: false
#| cache: true

# Load multiple seasons for age curve analysis
pbp_multi <- load_pbp(2018:2023)

# QB age curves
qb_age_curve <- pbp_multi %>%
  filter(!is.na(epa), !is.na(cpoe)) %>%
  # Join with roster data for ages
  left_join(
    load_rosters(2018:2023) %>%
      select(season, gsis_id, age = age, position),
    by = c("season", "passer_player_id" = "gsis_id")
  ) %>%
  filter(position == "QB") %>%
  group_by(passer, season, age) %>%
  summarise(
    dropbacks = n(),
    epa_per_play = mean(epa, na.rm = TRUE),
    cpoe = mean(cpoe, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(dropbacks >= 200)

# Calculate age curve (average by age)
age_curve_summary <- qb_age_curve %>%
  group_by(age) %>%
  summarise(
    qbs = n(),
    avg_epa = mean(epa_per_play, na.rm = TRUE),
    avg_cpoe = mean(cpoe, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(age >= 23, age <= 40, qbs >= 5)

age_curve_summary %>%
  gt() %>%
  cols_label(
    age = "Age",
    qbs = "QBs",
    avg_epa = "Avg EPA/Play",
    avg_cpoe = "Avg CPOE"
  ) %>%
  fmt_number(
    columns = c(avg_epa, avg_cpoe),
    decimals = 3
  ) %>%
  tab_header(
    title = "QB Performance by Age",
    subtitle = "2018-2023 Seasons (Min. 200 dropbacks per season)"
  )
#| label: age-curve-analysis-py
#| message: false
#| warning: false
#| cache: true

# Load multiple seasons
pbp_multi = nfl.import_pbp_data(range(2018, 2024))

# Load roster data for ages
rosters = nfl.import_seasonal_rosters(range(2018, 2024))

# QB age curves
qb_plays = (pbp_multi
    .query("epa.notna() & cpoe.notna()")
    .merge(rosters[['season', 'gsis_id', 'age', 'position']],
           left_on=['season', 'passer_player_id'],
           right_on=['season', 'gsis_id'],
           how='left')
    .query("position == 'QB'")
)

qb_age_curve = (qb_plays
    .groupby(['passer', 'season', 'age'])
    .agg(
        dropbacks=('epa', 'count'),
        epa_per_play=('epa', 'mean'),
        cpoe=('cpoe', 'mean')
    )
    .reset_index()
    .query('dropbacks >= 200')
)

# Calculate age curve
age_curve_summary = (qb_age_curve
    .groupby('age')
    .agg(
        qbs=('passer', 'count'),
        avg_epa=('epa_per_play', 'mean'),
        avg_cpoe=('cpoe', 'mean')
    )
    .reset_index()
    .query('23 <= age <= 40 & qbs >= 5')
)

print("\nQB Performance by Age (2018-2023)")
print("=" * 60)
print(age_curve_summary.to_string(index=False))

Visualizing Age Curves

#| label: fig-qb-age-curve
#| fig-cap: "QB Performance Age Curve (2018-2023)"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false

ggplot(age_curve_summary, aes(x = age, y = avg_epa)) +
  geom_line(color = "#013369", size = 1.2) +
  geom_point(aes(size = qbs), color = "#013369", alpha = 0.7) +
  geom_smooth(method = "loess", se = TRUE, color = "#D50A0A",
              linetype = "dashed", alpha = 0.2) +
  scale_size_continuous(range = c(3, 8), name = "Number of QBs") +
  labs(
    title = "Quarterback Performance by Age",
    subtitle = "Average EPA per Play | 2018-2023 Seasons",
    x = "Age",
    y = "Average EPA per Play",
    caption = "Data: nflfastR | Min. 200 dropbacks per season, 5+ QBs per age"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 14),
    legend.position = "right"
  )
#| label: fig-qb-age-curve-py
#| fig-cap: "QB Performance Age Curve - Python (2018-2023)"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false

from scipy.interpolate import make_interp_spline

fig, ax = plt.subplots(figsize=(10, 6))

# Plot line
ax.plot(age_curve_summary['age'], age_curve_summary['avg_epa'],
        color='#013369', linewidth=2, marker='o', markersize=8,
        label='Observed Average')

# Add smooth curve
x_smooth = np.linspace(age_curve_summary['age'].min(),
                       age_curve_summary['age'].max(), 300)
spl = make_interp_spline(age_curve_summary['age'],
                         age_curve_summary['avg_epa'], k=3)
y_smooth = spl(x_smooth)
ax.plot(x_smooth, y_smooth, color='#D50A0A', linestyle='--',
        alpha=0.6, linewidth=1.5, label='Smoothed Trend')

ax.set_xlabel('Age', fontsize=12)
ax.set_ylabel('Average EPA per Play', fontsize=12)
ax.set_title('Quarterback Performance by Age\n2018-2023 Seasons',
             fontsize=14, fontweight='bold', pad=20)
ax.legend(loc='best')
ax.grid(True, alpha=0.3)

plt.text(0.99, 0.01,
         'Data: nfl_data_py | Min. 200 dropbacks per season',
         transform=ax.transAxes, ha='right', va='bottom',
         fontsize=8, style='italic')

plt.tight_layout()
plt.show()

📊 Visualization Output

The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.

Typical Peak Ages by Position

  • QB: Peak 28-32, slow decline to 38+
  • RB: Peak 23-26, rapid decline after 28
  • WR: Peak 25-28, moderate decline to 32
  • TE: Peak 26-29, slower decline than WR
  • OL: Peak 27-30, stable performance to 33
  • DL: Peak 26-29, decline after 31
  • LB: Peak 25-28, moderate decline to 32
  • DB: Peak 26-29, decline after 31

Individual Variance

Population averages mask substantial individual variation:

  • Elite players (e.g., Tom Brady) maintain peak longer
  • Injury history accelerates decline
  • Position changes (RB to receiver) can extend careers
  • Playing style affects longevity (mobile QBs age differently)

Injury Risk and Durability

Injury History Analysis

#| label: injury-analysis
#| message: false
#| warning: false
#| eval: false

# Injury data analysis (requires injury database)
# This example shows games played analysis as proxy

library(nflreadr)

# Load player stats across multiple seasons
player_stats <- load_player_stats(2018:2023)

# Calculate games played patterns
durability <- player_stats %>%
  group_by(player_id, player_name, position) %>%
  summarise(
    seasons = n_distinct(season),
    games_played = n(),
    games_per_season = games_played / seasons,
    .groups = "drop"
  ) %>%
  filter(seasons >= 3)

# Position-level durability
position_durability <- durability %>%
  group_by(position) %>%
  summarise(
    players = n(),
    avg_games_per_season = mean(games_per_season),
    pct_play_16_plus = mean(games_per_season >= 16),
    .groups = "drop"
  ) %>%
  arrange(desc(avg_games_per_season))

position_durability %>%
  gt() %>%
  cols_label(
    position = "Position",
    players = "Players",
    avg_games_per_season = "Avg Games/Season",
    pct_play_16_plus = "% Playing 16+ Games"
  ) %>%
  fmt_number(
    columns = avg_games_per_season,
    decimals = 1
  ) %>%
  fmt_percent(
    columns = pct_play_16_plus,
    decimals = 1
  ) %>%
  tab_header(
    title = "Position Durability Analysis",
    subtitle = "2018-2023 Seasons (Players with 3+ seasons)"
  )
#| label: injury-analysis-py
#| message: false
#| warning: false
#| eval: false

# Games played analysis
player_stats = nfl.import_seasonal_data(range(2018, 2024))

# Calculate durability metrics
durability = (player_stats
    .groupby(['player_id', 'player_name', 'position'])
    .agg(
        seasons=('season', 'nunique'),
        games_played=('season', 'count')
    )
    .reset_index()
)

durability['games_per_season'] = (durability['games_played'] /
                                   durability['seasons'])

# Position-level analysis
position_durability = (durability
    .query('seasons >= 3')
    .groupby('position')
    .agg(
        players=('player_id', 'count'),
        avg_games_per_season=('games_per_season', 'mean'),
        pct_play_16_plus=('games_per_season', lambda x: (x >= 16).mean())
    )
    .reset_index()
    .sort_values('avg_games_per_season', ascending=False)
)

print("\nPosition Durability Analysis (2018-2023)")
print("=" * 70)
print(position_durability.to_string(index=False))

Injury Risk Factors

Key factors predicting future injuries:

  1. Past injury history: Best predictor of future injuries
  2. Age: Injury rate increases with age
  3. Position: RB, LB highest injury risk
  4. Body type: Weight and BMI correlations
  5. Playing style: Mobile QBs, power RBs higher risk

Predictive Player Performance Models

Season-to-Season Persistence

Different metrics show different year-to-year stability:

High Persistence (r > 0.6):
- QB completion percentage
- QB sack rate
- RB receiving yards per target
- WR yards per route run

Moderate Persistence (r = 0.4-0.6):
- QB EPA per play
- RB yards per carry
- WR EPA per target

Low Persistence (r < 0.4):
- TD rate (all positions)
- Turnover rate
- Big play rate

Regression to the Mean

Extreme performances regress toward league average:

$$ \text{Predicted Performance}_{\text{t+1}} = \alpha \cdot \text{Performance}_t + (1 - \alpha) \cdot \text{League Average} $$

Where $\alpha$ = persistence coefficient (0 to 1)

Building Predictive Models

#| label: predictive-model
#| message: false
#| warning: false
#| cache: true

# Build QB performance prediction model
# Using 2018-2022 to predict 2023

# Load historical data
pbp_hist <- load_pbp(2018:2022)
pbp_2023_actual <- load_pbp(2023)

# Calculate QB stats by season
qb_seasonal <- pbp_hist %>%
  filter(!is.na(epa), !is.na(cpoe)) %>%
  group_by(season, passer) %>%
  summarise(
    dropbacks = n(),
    epa_per_play = mean(epa, na.rm = TRUE),
    cpoe = mean(cpoe, na.rm = TRUE),
    success_rate = mean(epa > 0, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(dropbacks >= 200)

# Create lagged features (previous season predicts next)
qb_model_data <- qb_seasonal %>%
  arrange(passer, season) %>%
  group_by(passer) %>%
  mutate(
    next_epa = lead(epa_per_play),
    next_cpoe = lead(cpoe),
    next_success = lead(success_rate),
    seasons_experience = row_number()
  ) %>%
  filter(!is.na(next_epa)) %>%
  ungroup()

# Simple persistence model
library(broom)

persistence_model <- lm(
  next_epa ~ epa_per_play + cpoe + success_rate + seasons_experience,
  data = qb_model_data
)

# Model summary
tidy(persistence_model) %>%
  gt() %>%
  cols_label(
    term = "Variable",
    estimate = "Coefficient",
    std.error = "Std Error",
    statistic = "t-value",
    p.value = "p-value"
  ) %>%
  fmt_number(
    columns = c(estimate, std.error, statistic),
    decimals = 4
  ) %>%
  fmt_number(
    columns = p.value,
    decimals = 6
  ) %>%
  tab_header(
    title = "QB Performance Prediction Model",
    subtitle = "Predicting Next Season EPA from Current Season Metrics"
  )

# Model fit
cat("\nModel R-squared:", round(summary(persistence_model)$r.squared, 3), "\n")
cat("Adjusted R-squared:", round(summary(persistence_model)$adj.r.squared, 3), "\n")
#| label: predictive-model-py
#| message: false
#| warning: false
#| cache: true

from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score

# Load historical data
pbp_hist = nfl.import_pbp_data(range(2018, 2023))

# Calculate QB seasonal stats
qb_seasonal = (pbp_hist
    .query("epa.notna() & cpoe.notna()")
    .groupby(['season', 'passer'])
    .agg(
        dropbacks=('epa', 'count'),
        epa_per_play=('epa', 'mean'),
        cpoe=('cpoe', 'mean'),
        success_rate=('epa', lambda x: (x > 0).mean())
    )
    .reset_index()
    .query('dropbacks >= 200')
)

# Create lagged features
qb_seasonal = qb_seasonal.sort_values(['passer', 'season'])
qb_seasonal['next_epa'] = qb_seasonal.groupby('passer')['epa_per_play'].shift(-1)
qb_seasonal['next_cpoe'] = qb_seasonal.groupby('passer')['cpoe'].shift(-1)
qb_seasonal['seasons_experience'] = qb_seasonal.groupby('passer').cumcount() + 1

# Filter to complete cases
qb_model_data = qb_seasonal.dropna(subset=['next_epa'])

# Build prediction model
features = ['epa_per_play', 'cpoe', 'success_rate', 'seasons_experience']
X = qb_model_data[features]
y = qb_model_data['next_epa']

model = LinearRegression()
model.fit(X, y)

# Model summary
print("\nQB Performance Prediction Model")
print("=" * 60)
print("\nCoefficients:")
for feature, coef in zip(features, model.coef_):
    print(f"  {feature}: {coef:.4f}")
print(f"  Intercept: {model.intercept_:.4f}")

print(f"\nR-squared: {r2_score(y, model.predict(X)):.3f}")

Advanced Predictive Features

Improve predictions by including:

  1. Age and experience: Non-linear aging curves
  2. Team changes: New team/scheme effects
  3. Supporting cast changes: O-line, receivers
  4. Injury history: Missed games, injury types
  5. Workload: Previous season usage
  6. Contract year: Performance spikes in free agency year
  7. Coaching changes: New coordinator effects

Machine Learning Approaches

More sophisticated models:

#| label: ml-prediction
#| message: false
#| warning: false
#| eval: false

# Random forest for QB performance prediction
library(randomForest)

# Prepare features
feature_cols <- c("epa_per_play", "cpoe", "success_rate",
                  "seasons_experience", "dropbacks")

# Train random forest
rf_model <- randomForest(
  next_epa ~ .,
  data = qb_model_data %>% select(next_epa, all_of(feature_cols)),
  ntree = 500,
  mtry = 3,
  importance = TRUE
)

# Feature importance
importance(rf_model) %>%
  as.data.frame() %>%
  rownames_to_column("feature") %>%
  arrange(desc(`%IncMSE`)) %>%
  gt() %>%
  cols_label(
    feature = "Feature",
    `%IncMSE` = "Importance (% Inc MSE)"
  ) %>%
  fmt_number(
    columns = `%IncMSE`,
    decimals = 2
  ) %>%
  tab_header(
    title = "Feature Importance for QB Prediction",
    subtitle = "Random Forest Model"
  )

cat("\nRandom Forest R-squared:",
    round(rf_model$rsq[500], 3), "\n")
#| label: ml-prediction-py
#| message: false
#| warning: false
#| eval: false

from sklearn.ensemble import RandomForestRegressor

# Prepare data
features = ['epa_per_play', 'cpoe', 'success_rate',
            'seasons_experience', 'dropbacks']
X = qb_model_data[features]
y = qb_model_data['next_epa']

# Train random forest
rf_model = RandomForestRegressor(
    n_estimators=500,
    max_features=3,
    random_state=42
)
rf_model.fit(X, y)

# Feature importance
feature_importance = pd.DataFrame({
    'feature': features,
    'importance': rf_model.feature_importances_
}).sort_values('importance', ascending=False)

print("\nFeature Importance for QB Prediction")
print("=" * 50)
print(feature_importance.to_string(index=False))

print(f"\nRandom Forest R-squared: {r2_score(y, rf_model.predict(X)):.3f}")

Summary

Player evaluation is the foundation of personnel decisions. This chapter covered:

Isolation Challenges: Separating individual contributions from team context requires careful methodology—peer comparisons, regression adjustment, and tracking data.

Position-Specific Metrics: Each position demands tailored evaluation frameworks:
- QBs: EPA, CPOE, pressure metrics
- RBs: Dual-threat rushing and receiving value
- WRs/TEs: Separation, route running, YAC
- OL: Pass protection and run blocking efficiency
- Defensive players: Position-specific responsibilities

Aging Patterns: Position-specific age curves show when players peak and decline, with RBs aging fastest and QBs lasting longest.

Injury Risk: Historical injury patterns, position, and playing style predict future durability.

Predictive Modeling: Combining current performance, age, situation, and other factors enables forecasting future production for contracts and roster planning.

Effective player evaluation combines multiple metrics, accounts for context, and predicts future performance—not just measuring past production.

Exercises

Conceptual Questions

  1. Attribution Problem: Why is it more difficult to evaluate offensive linemen than quarterbacks? What data would help solve this problem?

  2. Age Curves: Why do running backs decline faster than quarterbacks? What physical and strategic factors explain this difference?

  3. Predictive Validity: Which QB metrics would you expect to have the highest year-to-year correlation? Why?

Coding Exercises

Exercise 1: Multi-Faceted QB Evaluation

Build a comprehensive quarterback evaluation system: a) Calculate EPA per play, CPOE, success rate, and sack rate for all QBs in 2023 b) Create separate rankings for each metric c) Build a composite score combining all metrics d) Compare composite rankings to traditional passer rating **Bonus**: Adjust metrics for opponent defensive strength.

Exercise 2: Running Back Age Curves

Analyze running back performance by age: a) Load play-by-play data from 2015-2023 b) Calculate EPA per touch for each RB by age c) Plot the age curve showing peak age and decline rate d) Identify RBs who defied the typical aging curve **Hint**: Use roster data to get player ages by season.

Exercise 3: Receiver Route Efficiency

Evaluate wide receivers on different route types: a) Categorize routes using air yards (short < 10, medium 10-20, deep 20+) b) Calculate EPA per target for each receiver on each route type c) Identify specialists (e.g., deep threats vs possession receivers) d) Visualize receiver archetypes **Bonus**: Include yards after catch metrics.

Exercise 4: Predictive Performance Model

Build a model to predict next season QB performance: a) Calculate QB metrics for 2020-2022 b) Create features: current year stats, age, experience, team changes c) Train a model predicting 2023 EPA per play d) Evaluate model accuracy and identify biggest prediction errors e) Analyze which features matter most **Advanced**: Try multiple models (linear regression, random forest, gradient boosting) and compare performance.

Further Reading

  • Eager, E. (2023). "The Athletic's QB Ranking Methodology." The Athletic.
  • Burke, B. (2020). "Evaluating NFL Quarterbacks Beyond Traditional Stats." ESPN Analytics.
  • Yurko, R., Ventura, S., & Horowitz, M. (2019). "nflWAR: A Reproducible Method for Offensive Player Evaluation in Football." Journal of Quantitative Analysis in Sports, 15(3), 163-183.
  • Schatz, A. (2023). "DVOA and DYAR Methodology." Football Outsiders.
  • Lopez, M. et al. (2020). "Bigger data, better questions, and a return to fourth down behavior." The Annals of Applied Statistics, 14(3), 1327-1345.

References

:::