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

Success Rate by Down & Distance

This heatmap shows how offensive success rates decline with increasing down number and distance to go.

  1. Understand success rate definitions and their applications in football analytics
  2. Compare success rate to EPA and recognize when to use each metric
  3. Distinguish between offensive consistency and explosiveness
  4. Apply success rate analysis to different game situations
  5. Use success rate metrics for play-calling optimization and strategic decision-making

Introduction

In football analytics, we often ask: "What makes an offense effective?" Expected Points Added (EPA) provides one answer by measuring the total value generated. But EPA alone doesn't tell the complete story. Consider two offenses:

  • Offense A: Consistently gains 4-5 yards per play, rarely punts, sustains long drives
  • Offense B: Frequently gains 0-2 yards, but generates big plays that result in the same total EPA

Both offenses might have identical EPA per play, but they achieve it very differently. Offense A is consistent, while Offense B is explosive. This distinction matters enormously for game planning, play-calling, and personnel decisions.

Success Rate addresses this gap by measuring how often an offense achieves "successful" plays—plays that keep drives alive and maintain positive game situations. Combined with EPA, success rate helps us understand both the consistency and explosiveness of an offense.

Why Success Rate Matters

Success rate answers a fundamental question: "Can this offense be trusted to pick up yards consistently?" High success rates correlate with: - Better third-down conversion rates - Longer time of possession - Fewer three-and-outs - More sustainable offensive performance - Better performance against quality defenses

In this chapter, we'll explore how success rate is defined, how it relates to EPA, and how to use both metrics together to build a complete picture of offensive efficiency.

Defining Success: The 40-60-100 Rule

The most commonly used success rate framework, pioneered by Football Outsiders, defines a "successful" play differently based on down:

$$ \text{Success} = \begin{cases} \text{Gain} \geq 0.40 \times \text{Yards to Go} & \text{if 1st down} \\ \text{Gain} \geq 0.60 \times \text{Yards to Go} & \text{if 2nd down} \\ \text{Gain} \geq 1.00 \times \text{Yards to Go} & \text{if 3rd/4th down} \end{cases} $$

Intuition Behind the Thresholds

These thresholds reflect the strategic reality of football:

First Down (40% threshold): On 1st-and-10, gaining 4+ yards puts you in a manageable 2nd-and-6 or better. This keeps the offense "on schedule" and preserves play-calling flexibility.

Second Down (60% threshold): On 2nd-and-6, gaining 4+ yards (60% of 6) gives you 3rd-and-2 or better—a high-probability conversion situation. Failing to reach 60% creates a difficult third down.

Third/Fourth Down (100% threshold): You must convert to be successful. Anything less means the drive likely ends.

Alternative Success Definitions

While 40-60-100 is standard, some analysts use different definitions: - **EPA-based success**: Any play with EPA > 0 is successful - **First down rate**: Only count conversions as successes - **Custom thresholds**: Adjust percentages based on field position or score For most analyses, stick with 40-60-100 for consistency with published research, but consider EPA > 0 as a complementary metric.

Examples of Success

Let's examine some concrete examples:

Down Distance Yards Gained Success? Reasoning
1st 10 5 Yes 5 ≥ 4.0 (40% of 10)
1st 10 3 No 3 < 4.0
2nd 7 4 No 4 < 4.2 (60% of 7)
2nd 7 5 Yes 5 ≥ 4.2
3rd 3 3 Yes Conversion!
3rd 3 2 No Failed to convert

Calculating Success Rate

Let's implement success rate calculation in both R and Python:

#| label: setup-r
#| message: false
#| warning: false

library(tidyverse)
library(nflfastR)
library(nflplotR)
library(gt)
library(scales)

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

# Calculate success for each play
pbp_with_success <- pbp %>%
  mutate(
    # 40-60-100 rule
    success_custom = case_when(
      down == 1 ~ yards_gained >= 0.4 * ydstogo,
      down == 2 ~ yards_gained >= 0.6 * ydstogo,
      down %in% c(3, 4) ~ yards_gained >= ydstogo,
      TRUE ~ NA
    )
  )

# Note: nflfastR already includes a 'success' variable based on EPA > 0
# We'll compare both definitions

cat("Sample plays with success indicators:\n")
pbp_with_success %>%
  filter(
    season == 2023,
    week == 1,
    !is.na(epa),
    play_type %in% c("pass", "run")
  ) %>%
  select(desc, down, ydstogo, yards_gained,
         success_custom, success, epa) %>%
  slice(1:5) %>%
  print()
#| label: setup-py
#| message: false
#| warning: false

import pandas as pd
import numpy as np
import nfl_data_py as nfl
import matplotlib.pyplot as plt
import seaborn as sns

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

# Calculate success for each play
def calculate_success(row):
    """Calculate success using 40-60-100 rule"""
    if pd.isna(row['down']) or pd.isna(row['ydstogo']) or pd.isna(row['yards_gained']):
        return np.nan

    if row['down'] == 1:
        return row['yards_gained'] >= 0.4 * row['ydstogo']
    elif row['down'] == 2:
        return row['yards_gained'] >= 0.6 * row['ydstogo']
    elif row['down'] in [3, 4]:
        return row['yards_gained'] >= row['ydstogo']
    else:
        return np.nan

# Apply success calculation
pbp['success_custom'] = pbp.apply(calculate_success, axis=1)

# Display sample
print("\nSample plays with success indicators:")
sample = (pbp
    .query("season == 2023 & week == 1 & epa.notna() & play_type.isin(['pass', 'run'])")
    [['desc', 'down', 'ydstogo', 'yards_gained', 'success_custom', 'success', 'epa']]
    .head(5)
)
print(sample.to_string(index=False))

Team Success Rate

Now let's calculate team-level success rates:

#| label: team-success-rate-r
#| message: false
#| warning: false
#| cache: true

# Calculate team success rates for 2023
team_success <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(posteam),
    play_type %in% c("pass", "run")
  ) %>%
  group_by(posteam) %>%
  summarise(
    plays = n(),
    success_rate_epa = mean(success, na.rm = TRUE),
    success_rate_custom = mean(success_custom, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  arrange(desc(success_rate_epa))

# Display top teams
team_success %>%
  head(10) %>%
  gt() %>%
  cols_label(
    posteam = "Team",
    plays = "Plays",
    success_rate_epa = "Success Rate (EPA)",
    success_rate_custom = "Success Rate (40-60-100)",
    epa_per_play = "EPA/Play"
  ) %>%
  fmt_percent(
    columns = c(success_rate_epa, success_rate_custom),
    decimals = 1
  ) %>%
  fmt_number(
    columns = epa_per_play,
    decimals = 3
  ) %>%
  fmt_number(
    columns = plays,
    decimals = 0,
    use_seps = TRUE
  ) %>%
  tab_header(
    title = "Top 10 Teams by Success Rate",
    subtitle = "2023 Regular Season"
  ) %>%
  tab_source_note("Data: nflfastR | Success Rate (EPA) = EPA > 0")
#| label: team-success-rate-py
#| message: false
#| warning: false
#| cache: true

# Calculate team success rates for 2023
team_success = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & posteam.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .groupby('posteam')
    .agg(
        plays=('epa', 'count'),
        success_rate_epa=('success', lambda x: x.mean()),
        success_rate_custom=('success_custom', lambda x: x.mean()),
        epa_per_play=('epa', 'mean')
    )
    .reset_index()
    .sort_values('success_rate_epa', ascending=False)
)

print("\nTop 10 Teams by Success Rate (2023 Regular Season):")
print(team_success.head(10).to_string(index=False))
print("\nNote: Success Rate (EPA) = EPA > 0")

Two Success Definitions

Notice we calculated success two ways: 1. **EPA-based** (`success` in nflfastR): Any play with EPA > 0 2. **Yards-based** (`success_custom`): The 40-60-100 rule Both are valid! EPA-based success accounts for field position and game context, while yards-based success is more intuitive and stable across seasons. We'll primarily use EPA-based success in this chapter, but both tell similar stories.

Success Rate vs EPA: Consistency vs Explosiveness

Success rate and EPA measure different aspects of offensive performance:

  • Success Rate: Measures consistency—how often do you achieve positive outcomes?
  • EPA: Measures total value—how many points do you add on average?

An offense can be:
- High success, high EPA: Efficient and explosive (elite offenses)
- High success, low EPA: Consistent but not explosive (grinding offenses)
- Low success, high EPA: Boom-or-bust (explosive but inconsistent)
- Low success, low EPA: Inefficient and inconsistent (struggling offenses)

Visualizing the Relationship

#| label: fig-success-vs-epa-r
#| fig-cap: "Team Success Rate vs EPA per Play (2023)"
#| fig-width: 10
#| fig-height: 10
#| message: false
#| warning: false
#| cache: true

# Create scatter plot with quadrants
team_success %>%
  ggplot(aes(x = success_rate_epa, y = epa_per_play)) +
  geom_vline(xintercept = mean(team_success$success_rate_epa),
             linetype = "dashed", alpha = 0.5) +
  geom_hline(yintercept = mean(team_success$epa_per_play),
             linetype = "dashed", alpha = 0.5) +
  geom_nfl_logos(aes(team_abbr = posteam), width = 0.05, alpha = 0.8) +
  # Add quadrant labels
  annotate("text", x = 0.42, y = 0.12,
           label = "Consistent &\nExplosive",
           size = 4, fontface = "bold", color = "darkgreen") +
  annotate("text", x = 0.42, y = -0.12,
           label = "Consistent\nNot Explosive",
           size = 4, fontface = "bold", color = "orange") +
  annotate("text", x = 0.50, y = 0.12,
           label = "Explosive\nNot Consistent",
           size = 4, fontface = "bold", color = "purple") +
  annotate("text", x = 0.50, y = -0.12,
           label = "Neither\nConsistent nor Explosive",
           size = 4, fontface = "bold", color = "red") +
  scale_x_continuous(labels = percent_format(accuracy = 1)) +
  labs(
    title = "Offensive Efficiency Matrix: Success Rate vs EPA",
    subtitle = "2023 Regular Season | Quadrants show offensive archetypes",
    x = "Success Rate (% of plays with EPA > 0)",
    y = "EPA per Play",
    caption = "Data: nflfastR | Top-right quadrant = Elite offenses"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 16),
    plot.subtitle = element_text(size = 11),
    panel.grid.minor = element_blank()
  )
#| label: fig-success-vs-epa-py
#| fig-cap: "Team Success Rate vs EPA per Play - Python (2023)"
#| fig-width: 10
#| fig-height: 10
#| message: false
#| warning: false
#| cache: true

# Load team colors
teams = nfl.import_team_desc()
team_colors = dict(zip(teams['team_abbr'], teams['team_color']))

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

# Add reference lines
mean_sr = team_success['success_rate_epa'].mean()
mean_epa = team_success['epa_per_play'].mean()
ax.axvline(x=mean_sr, linestyle='--', alpha=0.5, color='gray')
ax.axhline(y=mean_epa, linestyle='--', alpha=0.5, color='gray')

# Plot teams
for _, row in team_success.iterrows():
    team = row['posteam']
    color = team_colors.get(team, '#333333')
    ax.scatter(row['success_rate_epa'], row['epa_per_play'],
               s=300, color=color, alpha=0.6, edgecolors='white', linewidth=2)
    ax.text(row['success_rate_epa'], row['epa_per_play'], team,
            ha='center', va='center', fontsize=8, fontweight='bold')

# Add quadrant labels
ax.text(0.42, 0.12, 'Consistent &\nExplosive',
        fontsize=11, fontweight='bold', color='darkgreen', ha='center')
ax.text(0.42, -0.12, 'Consistent\nNot Explosive',
        fontsize=11, fontweight='bold', color='orange', ha='center')
ax.text(0.50, 0.12, 'Explosive\nNot Consistent',
        fontsize=11, fontweight='bold', color='purple', ha='center')
ax.text(0.50, -0.12, 'Neither\nConsistent nor Explosive',
        fontsize=11, fontweight='bold', color='red', ha='center')

# Format axes
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.0%}'))
ax.set_xlabel('Success Rate (% of plays with EPA > 0)', fontsize=12)
ax.set_ylabel('EPA per Play', fontsize=12)
ax.set_title('Offensive Efficiency Matrix: Success Rate vs EPA\n2023 Regular Season | Quadrants show offensive archetypes',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.99, 0.01, 'Data: nfl_data_py | Top-right quadrant = Elite offenses',
        transform=ax.transAxes, ha='right', va='bottom', fontsize=8, style='italic')
ax.grid(alpha=0.3)

plt.tight_layout()
plt.show()

Correlation Analysis

#| label: correlation-analysis-r
#| message: false
#| warning: false

# Calculate correlation
correlation <- cor(team_success$success_rate_epa,
                   team_success$epa_per_play,
                   use = "complete.obs")

cat("Correlation between Success Rate and EPA:", round(correlation, 3), "\n\n")

# Regression analysis
model <- lm(epa_per_play ~ success_rate_epa, data = team_success)
summary(model)

cat("\nInterpretation:")
cat("\n- R-squared:", round(summary(model)$r.squared, 3))
cat("\n- A 1% increase in success rate is associated with a",
    round(coef(model)[2] * 0.01, 3), "increase in EPA per play\n")
#| label: correlation-analysis-py
#| message: false
#| warning: false

from scipy import stats

# Calculate correlation
correlation = team_success[['success_rate_epa', 'epa_per_play']].corr().iloc[0, 1]
print(f"Correlation between Success Rate and EPA: {correlation:.3f}\n")

# Regression analysis
from sklearn.linear_model import LinearRegression

X = team_success[['success_rate_epa']].values
y = team_success['epa_per_play'].values

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

r_squared = model.score(X, y)

print(f"R-squared: {r_squared:.3f}")
print(f"Coefficient: {model.coef_[0]:.3f}")
print(f"\nInterpretation:")
print(f"- A 1% increase in success rate is associated with a {model.coef_[0] * 0.01:.3f} increase in EPA per play")

The strong positive correlation (typically r > 0.85) shows that success rate and EPA measure related but distinct concepts. Teams that succeed more often also generate more EPA—but the quadrants show important variations.

Success Rate by Play Type

Different play types have different success rate profiles:

#| label: success-by-playtype-r
#| message: false
#| warning: false
#| cache: true

# Calculate success rate by play type
playtype_success <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(posteam),
    play_type %in% c("pass", "run")
  ) %>%
  group_by(play_type) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    explosive_rate = mean(epa > 1.0, na.rm = TRUE),
    .groups = "drop"
  )

playtype_success %>%
  gt() %>%
  cols_label(
    play_type = "Play Type",
    plays = "Plays",
    success_rate = "Success Rate",
    epa_per_play = "EPA/Play",
    explosive_rate = "Explosive Play %"
  ) %>%
  fmt_percent(
    columns = c(success_rate, explosive_rate),
    decimals = 1
  ) %>%
  fmt_number(
    columns = epa_per_play,
    decimals = 3
  ) %>%
  fmt_number(
    columns = plays,
    decimals = 0,
    use_seps = TRUE
  ) %>%
  tab_header(
    title = "Success Rate by Play Type",
    subtitle = "2023 Regular Season"
  ) %>%
  tab_source_note("Explosive Play = EPA > 1.0")
#| label: success-by-playtype-py
#| message: false
#| warning: false
#| cache: true

# Calculate success rate by play type
playtype_success = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & posteam.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .groupby('play_type')
    .agg(
        plays=('epa', 'count'),
        success_rate=('success', 'mean'),
        epa_per_play=('epa', 'mean'),
        explosive_rate=('epa', lambda x: (x > 1.0).mean())
    )
    .reset_index()
)

print("\nSuccess Rate by Play Type (2023 Regular Season):")
print(playtype_success.to_string(index=False))
print("\nNote: Explosive Play = EPA > 1.0")

Key Insights: Pass vs Run

Passing plays typically show: - **Higher EPA per play**: More valuable when successful - **Similar success rates**: Comparable consistency to rushing - **Higher explosive rates**: More big plays Running plays typically show: - **Lower EPA per play**: Less valuable on average - **Similar success rates**: Can be just as consistent - **Lower explosive rates**: Fewer big plays This demonstrates why EPA favors passing—it's not just about consistency, but about the upside when plays succeed.

Team-Level Pass vs Run Success

#| label: team-pass-run-success-r
#| message: false
#| warning: false
#| cache: true

# Calculate by team and play type
team_playtype <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(posteam),
    play_type %in% c("pass", "run")
  ) %>%
  group_by(posteam, play_type) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  pivot_wider(
    names_from = play_type,
    values_from = c(success_rate, epa_per_play, plays),
    names_glue = "{play_type}_"
  )

# Display top teams
team_playtype %>%
  arrange(desc(pass_epa_per_play)) %>%
  select(posteam, pass_success_rate, run_success_rate,
         pass_epa_per_play, run_epa_per_play) %>%
  head(10) %>%
  gt() %>%
  cols_label(
    posteam = "Team",
    pass_success_rate = "Pass SR",
    run_success_rate = "Run SR",
    pass_epa_per_play = "Pass EPA",
    run_epa_per_play = "Run EPA"
  ) %>%
  fmt_percent(
    columns = c(pass_success_rate, run_success_rate),
    decimals = 1
  ) %>%
  fmt_number(
    columns = c(pass_epa_per_play, run_epa_per_play),
    decimals = 3
  ) %>%
  tab_header(
    title = "Top Teams: Pass vs Run Success",
    subtitle = "2023 Regular Season, ranked by Pass EPA"
  )
#| label: team-pass-run-success-py
#| message: false
#| warning: false
#| cache: true

# Calculate by team and play type
team_playtype = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & posteam.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .groupby(['posteam', 'play_type'])
    .agg(
        plays=('epa', 'count'),
        success_rate=('success', 'mean'),
        epa_per_play=('epa', 'mean')
    )
    .reset_index()
)

# Pivot to wide format
team_playtype_wide = team_playtype.pivot(
    index='posteam',
    columns='play_type',
    values=['success_rate', 'epa_per_play']
).reset_index()

# Flatten column names
team_playtype_wide.columns = ['_'.join(col).strip('_') if col[1] else col[0]
                                for col in team_playtype_wide.columns.values]

# Sort and display
team_playtype_wide = team_playtype_wide.sort_values('epa_per_play_pass', ascending=False)

print("\nTop 10 Teams: Pass vs Run Success (2023, ranked by Pass EPA):")
print(team_playtype_wide[['posteam', 'success_rate_pass', 'success_rate_run',
                          'epa_per_play_pass', 'epa_per_play_run']].head(10).to_string(index=False))

Success Rate by Down and Distance

Success rates vary dramatically by situation:

#| label: fig-success-by-down-r
#| fig-cap: "Success Rate by Down (2023 season)"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
#| cache: true

# Calculate success rate by down
down_success <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(down),
    play_type %in% c("pass", "run")
  ) %>%
  group_by(down, play_type) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    .groups = "drop"
  )

# Create bar chart
down_success %>%
  ggplot(aes(x = factor(down), y = success_rate, fill = play_type)) +
  geom_col(position = "dodge", alpha = 0.8) +
  geom_text(aes(label = percent(success_rate, accuracy = 0.1)),
            position = position_dodge(width = 0.9),
            vjust = -0.5, size = 3.5) +
  scale_fill_manual(
    values = c("pass" = "#00BFC4", "run" = "#F8766D"),
    labels = c("Pass", "Run")
  ) +
  scale_y_continuous(labels = percent_format(), limits = c(0, 0.6)) +
  labs(
    title = "Success Rate by Down and Play Type",
    subtitle = "2023 Regular Season",
    x = "Down",
    y = "Success Rate",
    fill = "Play Type",
    caption = "Data: nflfastR"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 14),
    legend.position = "top",
    panel.grid.major.x = element_blank()
  )

📊 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-success-by-down-py
#| fig-cap: "Success Rate by Down - Python (2023 season)"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
#| cache: true

# Calculate success rate by down
down_success = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & down.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .groupby(['down', 'play_type'])
    .agg(
        plays=('success', 'count'),
        success_rate=('success', 'mean')
    )
    .reset_index()
)

# Create grouped bar chart
fig, ax = plt.subplots(figsize=(10, 6))

x = np.arange(4)  # 4 downs
width = 0.35

pass_data = down_success[down_success['play_type'] == 'pass'].sort_values('down')
run_data = down_success[down_success['play_type'] == 'run'].sort_values('down')

bars1 = ax.bar(x - width/2, pass_data['success_rate'], width,
               label='Pass', alpha=0.8, color='#00BFC4')
bars2 = ax.bar(x + width/2, run_data['success_rate'], width,
               label='Run', alpha=0.8, color='#F8766D')

# Add value labels
for bars in [bars1, bars2]:
    for bar in bars:
        height = bar.get_height()
        ax.text(bar.get_x() + bar.get_width()/2., height,
                f'{height:.1%}', ha='center', va='bottom', fontsize=10)

ax.set_xlabel('Down', fontsize=12)
ax.set_ylabel('Success Rate', fontsize=12)
ax.set_title('Success Rate by Down and Play Type\n2023 Regular Season',
             fontsize=14, fontweight='bold', pad=20)
ax.set_xticks(x)
ax.set_xticklabels(['1st', '2nd', '3rd', '4th'])
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, p: f'{y:.0%}'))
ax.legend(title='Play Type', loc='upper right')
ax.set_ylim(0, 0.6)
ax.grid(axis='y', alpha=0.3)
ax.text(0.99, 0.01, 'Data: nfl_data_py',
        transform=ax.transAxes, ha='right', va='bottom', fontsize=8, style='italic')

plt.tight_layout()
plt.show()

Distance to Go Analysis

#| label: fig-success-by-distance-r
#| fig-cap: "Success Rate by Yards to Go (2023 season)"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
#| cache: true

# Create distance buckets
distance_success <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(ydstogo),
    play_type %in% c("pass", "run"),
    ydstogo <= 20  # Focus on reasonable distances
  ) %>%
  mutate(
    distance_bucket = case_when(
      ydstogo <= 3 ~ "1-3 yards",
      ydstogo <= 6 ~ "4-6 yards",
      ydstogo <= 9 ~ "7-9 yards",
      ydstogo <= 15 ~ "10-15 yards",
      TRUE ~ "16+ yards"
    ),
    distance_bucket = factor(distance_bucket,
                            levels = c("1-3 yards", "4-6 yards", "7-9 yards",
                                     "10-15 yards", "16+ yards"))
  ) %>%
  group_by(distance_bucket, play_type) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    .groups = "drop"
  )

# Create line plot
distance_success %>%
  ggplot(aes(x = distance_bucket, y = success_rate,
             color = play_type, group = play_type)) +
  geom_line(size = 1.5, alpha = 0.8) +
  geom_point(size = 3) +
  scale_color_manual(
    values = c("pass" = "#00BFC4", "run" = "#F8766D"),
    labels = c("Pass", "Run")
  ) +
  scale_y_continuous(labels = percent_format()) +
  labs(
    title = "Success Rate by Distance to Go",
    subtitle = "2023 Regular Season",
    x = "Yards to Go",
    y = "Success Rate",
    color = "Play Type",
    caption = "Data: nflfastR"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 14),
    legend.position = "top",
    axis.text.x = element_text(angle = 45, hjust = 1)
  )

📊 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-success-by-distance-py
#| fig-cap: "Success Rate by Yards to Go - Python (2023 season)"
#| fig-width: 10
#| fig-height: 6
#| message: false
#| warning: false
#| cache: true

# Create distance buckets
def bucket_distance(ydstogo):
    if ydstogo <= 3:
        return "1-3 yards"
    elif ydstogo <= 6:
        return "4-6 yards"
    elif ydstogo <= 9:
        return "7-9 yards"
    elif ydstogo <= 15:
        return "10-15 yards"
    else:
        return "16+ yards"

distance_data = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & ydstogo.notna()")
    .query("play_type.isin(['pass', 'run']) & ydstogo <= 20")
    .copy()
)

distance_data['distance_bucket'] = distance_data['ydstogo'].apply(bucket_distance)

# Calculate success rates
distance_success = (distance_data
    .groupby(['distance_bucket', 'play_type'])
    .agg(
        plays=('success', 'count'),
        success_rate=('success', 'mean'),
        epa_per_play=('epa', 'mean')
    )
    .reset_index()
)

# Order buckets
bucket_order = ["1-3 yards", "4-6 yards", "7-9 yards", "10-15 yards", "16+ yards"]
distance_success['distance_bucket'] = pd.Categorical(
    distance_success['distance_bucket'],
    categories=bucket_order,
    ordered=True
)
distance_success = distance_success.sort_values(['play_type', 'distance_bucket'])

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

for play_type, color in [('pass', '#00BFC4'), ('run', '#F8766D')]:
    data = distance_success[distance_success['play_type'] == play_type]
    ax.plot(range(len(data)), data['success_rate'],
            marker='o', linewidth=2.5, label=play_type.title(),
            color=color, alpha=0.8, markersize=8)

ax.set_xticks(range(len(bucket_order)))
ax.set_xticklabels(bucket_order, rotation=45, ha='right')
ax.set_xlabel('Yards to Go', fontsize=12)
ax.set_ylabel('Success Rate', fontsize=12)
ax.set_title('Success Rate by Distance to Go\n2023 Regular Season',
             fontsize=14, fontweight='bold', pad=20)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, p: f'{y:.0%}'))
ax.legend(title='Play Type', loc='best')
ax.grid(alpha=0.3)
ax.text(0.99, 0.01, 'Data: nfl_data_py',
        transform=ax.transAxes, ha='right', va='bottom', fontsize=8, style='italic')

plt.tight_layout()
plt.show()

Key Patterns by Distance

- **Short yardage (1-3)**: Running has higher success rate due to quarterback sneaks and power formations - **Medium yardage (4-9)**: Passing and running have similar success rates - **Long yardage (10+)**: Passing becomes more successful, as running rarely gains enough yards This illustrates why teams pass more often in obvious passing situations—not just because they have to, but because it's more likely to succeed.

Situational Success Rates

Success rates vary by field position, score, and time:

#| label: situational-success-r
#| message: false
#| warning: false
#| cache: true

# Field position analysis
field_position_success <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(yardline_100),
    play_type %in% c("pass", "run")
  ) %>%
  mutate(
    field_zone = case_when(
      yardline_100 >= 80 ~ "Own 20 or worse",
      yardline_100 >= 50 ~ "Own 21 to Midfield",
      yardline_100 >= 20 ~ "Opp 49 to 20",
      TRUE ~ "Red Zone (inside 20)"
    ),
    field_zone = factor(field_zone,
                       levels = c("Own 20 or worse", "Own 21 to Midfield",
                                "Opp 49 to 20", "Red Zone (inside 20)"))
  ) %>%
  group_by(field_zone, play_type) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    .groups = "drop"
  )

field_position_success %>%
  gt() %>%
  cols_label(
    field_zone = "Field Position",
    play_type = "Play Type",
    plays = "Plays",
    success_rate = "Success Rate",
    epa_per_play = "EPA/Play"
  ) %>%
  fmt_percent(
    columns = success_rate,
    decimals = 1
  ) %>%
  fmt_number(
    columns = epa_per_play,
    decimals = 3
  ) %>%
  fmt_number(
    columns = plays,
    decimals = 0,
    use_seps = TRUE
  ) %>%
  tab_header(
    title = "Success Rate by Field Position",
    subtitle = "2023 Regular Season"
  )
#| label: situational-success-py
#| message: false
#| warning: false
#| cache: true

# Field position analysis
def classify_field_position(yardline_100):
    if yardline_100 >= 80:
        return "Own 20 or worse"
    elif yardline_100 >= 50:
        return "Own 21 to Midfield"
    elif yardline_100 >= 20:
        return "Opp 49 to 20"
    else:
        return "Red Zone (inside 20)"

field_data = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & yardline_100.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .copy()
)

field_data['field_zone'] = field_data['yardline_100'].apply(classify_field_position)

field_position_success = (field_data
    .groupby(['field_zone', 'play_type'])
    .agg(
        plays=('success', 'count'),
        success_rate=('success', 'mean'),
        epa_per_play=('epa', 'mean')
    )
    .reset_index()
)

# Order zones
zone_order = ["Own 20 or worse", "Own 21 to Midfield", "Opp 49 to 20", "Red Zone (inside 20)"]
field_position_success['field_zone'] = pd.Categorical(
    field_position_success['field_zone'],
    categories=zone_order,
    ordered=True
)
field_position_success = field_position_success.sort_values(['field_zone', 'play_type'])

print("\nSuccess Rate by Field Position (2023 Regular Season):")
print(field_position_success.to_string(index=False))

Success Rate Stability and Predictability

How stable is success rate across time? Does it predict future performance?

#| label: success-stability-r
#| message: false
#| warning: false
#| cache: true

# Calculate first half and second half of season
team_half_success <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(posteam),
    play_type %in% c("pass", "run")
  ) %>%
  mutate(
    half = if_else(week <= 9, "First Half", "Second Half")
  ) %>%
  group_by(posteam, half) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  pivot_wider(
    names_from = half,
    values_from = c(success_rate, epa_per_play, plays)
  )

# Calculate correlations
sr_correlation <- cor(team_half_success$`success_rate_First Half`,
                     team_half_success$`success_rate_Second Half`,
                     use = "complete.obs")

epa_correlation <- cor(team_half_success$`epa_per_play_First Half`,
                      team_half_success$`epa_per_play_Second Half`,
                      use = "complete.obs")

cat("Within-Season Stability (2023):\n")
cat("Success Rate correlation (H1 vs H2):", round(sr_correlation, 3), "\n")
cat("EPA correlation (H1 vs H2):", round(epa_correlation, 3), "\n\n")

cat("Interpretation: Success rate has",
    if_else(sr_correlation > epa_correlation, "HIGHER", "LOWER"),
    "within-season stability than EPA\n")
#| label: success-stability-py
#| message: false
#| warning: false
#| cache: true

# Calculate first half and second half of season
half_data = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & posteam.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .copy()
)

half_data['half'] = half_data['week'].apply(lambda x: 'First Half' if x <= 9 else 'Second Half')

team_half_success = (half_data
    .groupby(['posteam', 'half'])
    .agg(
        plays=('success', 'count'),
        success_rate=('success', 'mean'),
        epa_per_play=('epa', 'mean')
    )
    .reset_index()
    .pivot(index='posteam', columns='half',
           values=['success_rate', 'epa_per_play', 'plays'])
    .reset_index()
)

# Flatten column names
team_half_success.columns = ['_'.join(col).strip('_') if col[1] else col[0]
                              for col in team_half_success.columns.values]

# Calculate correlations
sr_correlation = team_half_success['success_rate_First Half'].corr(
    team_half_success['success_rate_Second Half']
)

epa_correlation = team_half_success['epa_per_play_First Half'].corr(
    team_half_success['epa_per_play_Second Half']
)

print("\nWithin-Season Stability (2023):")
print(f"Success Rate correlation (H1 vs H2): {sr_correlation:.3f}")
print(f"EPA correlation (H1 vs H2): {epa_correlation:.3f}\n")

comparison = "HIGHER" if sr_correlation > epa_correlation else "LOWER"
print(f"Interpretation: Success rate has {comparison} within-season stability than EPA")

Year-to-Year Stability

#| label: year-to-year-stability-r
#| message: false
#| warning: false
#| cache: true

# Calculate by season
team_season_metrics <- pbp %>%
  filter(
    season %in% c(2022, 2023),
    season_type == "REG",
    !is.na(epa),
    !is.na(posteam),
    play_type %in% c("pass", "run")
  ) %>%
  group_by(posteam, season) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  pivot_wider(
    names_from = season,
    values_from = c(success_rate, epa_per_play, plays),
    names_glue = "_{season}"
  )

# Calculate year-to-year correlations
sr_yoy <- cor(team_season_metrics$success_rate_2022,
             team_season_metrics$success_rate_2023,
             use = "complete.obs")

epa_yoy <- cor(team_season_metrics$epa_per_play_2022,
              team_season_metrics$epa_per_play_2023,
              use = "complete.obs")

cat("Year-to-Year Stability (2022 to 2023):\n")
cat("Success Rate correlation:", round(sr_yoy, 3), "\n")
cat("EPA correlation:", round(epa_yoy, 3), "\n\n")

cat("Interpretation: Both metrics show moderate year-to-year stability,")
cat("\nbut success rate is slightly",
    if_else(sr_yoy > epa_yoy, "MORE", "LESS"),
    "stable than EPA\n")
#| label: year-to-year-stability-py
#| message: false
#| warning: false
#| cache: true

# Calculate by season
season_data = (pbp
    .query("season.isin([2022, 2023]) & season_type == 'REG' & epa.notna() & posteam.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .groupby(['posteam', 'season'])
    .agg(
        plays=('success', 'count'),
        success_rate=('success', 'mean'),
        epa_per_play=('epa', 'mean')
    )
    .reset_index()
)

# Pivot to wide format
team_season_metrics = (season_data
    .pivot(index='posteam', columns='season',
           values=['success_rate', 'epa_per_play', 'plays'])
    .reset_index()
)

# Flatten column names
team_season_metrics.columns = ['_'.join(map(str, col)).strip('_') if col[1] else col[0]
                                for col in team_season_metrics.columns.values]

# Calculate year-to-year correlations
sr_yoy = team_season_metrics['success_rate_2022'].corr(
    team_season_metrics['success_rate_2023']
)

epa_yoy = team_season_metrics['epa_per_play_2022'].corr(
    team_season_metrics['epa_per_play_2023']
)

print("\nYear-to-Year Stability (2022 to 2023):")
print(f"Success Rate correlation: {sr_yoy:.3f}")
print(f"EPA correlation: {epa_yoy:.3f}\n")

comparison = "MORE" if sr_yoy > epa_yoy else "LESS"
print(f"Interpretation: Both metrics show moderate year-to-year stability,")
print(f"but success rate is slightly {comparison} stable than EPA")

Stability Insights

Both success rate and EPA show: - **High within-season stability** (r > 0.70): Teams maintain consistent performance levels week-to-week - **Moderate year-to-year stability** (r ~ 0.40-0.60): Performance changes with roster turnover, coaching changes, and schedule difficulty Success rate tends to be slightly more stable than EPA, making it potentially more reliable for predicting future performance. However, both metrics are valuable and tell complementary stories.

Using Success Rate for Game Planning

How can coordinators use success rate for strategic decisions?

Identifying Opponent Tendencies

#| label: opponent-analysis-r
#| message: false
#| warning: false
#| cache: true

# Analyze defensive success rate allowed by down
defensive_success <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(defteam),
    play_type %in% c("pass", "run")
  ) %>%
  group_by(defteam, down) %>%
  summarise(
    plays = n(),
    success_rate_allowed = mean(success, na.rm = TRUE),
    epa_allowed = mean(epa, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(plays >= 30)  # Minimum sample size

# Show worst defenses on 3rd down
defensive_success %>%
  filter(down == 3) %>%
  arrange(desc(success_rate_allowed)) %>%
  head(10) %>%
  gt() %>%
  cols_label(
    defteam = "Defense",
    down = "Down",
    plays = "Plays",
    success_rate_allowed = "Success Rate Allowed",
    epa_allowed = "EPA Allowed"
  ) %>%
  fmt_percent(
    columns = success_rate_allowed,
    decimals = 1
  ) %>%
  fmt_number(
    columns = epa_allowed,
    decimals = 3
  ) %>%
  fmt_number(
    columns = plays,
    decimals = 0,
    use_seps = TRUE
  ) %>%
  tab_header(
    title = "Worst 3rd Down Defenses",
    subtitle = "Highest success rate allowed, 2023 Regular Season"
  )
#| label: opponent-analysis-py
#| message: false
#| warning: false
#| cache: true

# Analyze defensive success rate allowed by down
defensive_success = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & defteam.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .groupby(['defteam', 'down'])
    .agg(
        plays=('success', 'count'),
        success_rate_allowed=('success', 'mean'),
        epa_allowed=('epa', 'mean')
    )
    .reset_index()
    .query("plays >= 30")  # Minimum sample size
)

# Show worst defenses on 3rd down
third_down_defense = (defensive_success
    .query("down == 3")
    .sort_values('success_rate_allowed', ascending=False)
    .head(10)
)

print("\nWorst 3rd Down Defenses (Highest success rate allowed, 2023):")
print(third_down_defense.to_string(index=False))

Play-Calling Optimization

#| label: playcalling-optimization-r
#| message: false
#| warning: false
#| cache: true

# Analyze optimal play mix by game script
game_script_success <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(score_differential),
    play_type %in% c("pass", "run")
  ) %>%
  mutate(
    game_script = case_when(
      score_differential >= 10 ~ "Winning big (10+)",
      score_differential >= 4 ~ "Winning (4-9)",
      score_differential >= -3 ~ "Close game",
      score_differential >= -9 ~ "Losing (4-9)",
      TRUE ~ "Losing big (10+)"
    ),
    game_script = factor(game_script,
                        levels = c("Winning big (10+)", "Winning (4-9)",
                                 "Close game", "Losing (4-9)", "Losing big (10+)"))
  ) %>%
  group_by(game_script, play_type) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    .groups = "drop"
  )

game_script_success %>%
  gt() %>%
  cols_label(
    game_script = "Game Script",
    play_type = "Play Type",
    plays = "Plays",
    success_rate = "Success Rate",
    epa_per_play = "EPA/Play"
  ) %>%
  fmt_percent(
    columns = success_rate,
    decimals = 1
  ) %>%
  fmt_number(
    columns = epa_per_play,
    decimals = 3
  ) %>%
  fmt_number(
    columns = plays,
    decimals = 0,
    use_seps = TRUE
  ) %>%
  tab_header(
    title = "Success Rate by Game Script",
    subtitle = "2023 Regular Season"
  ) %>%
  tab_source_note("Game script based on score differential")
#| label: playcalling-optimization-py
#| message: false
#| warning: false
#| cache: true

# Analyze optimal play mix by game script
def classify_game_script(score_diff):
    if score_diff >= 10:
        return "Winning big (10+)"
    elif score_diff >= 4:
        return "Winning (4-9)"
    elif score_diff >= -3:
        return "Close game"
    elif score_diff >= -9:
        return "Losing (4-9)"
    else:
        return "Losing big (10+)"

script_data = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & score_differential.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .copy()
)

script_data['game_script'] = script_data['score_differential'].apply(classify_game_script)

game_script_success = (script_data
    .groupby(['game_script', 'play_type'])
    .agg(
        plays=('success', 'count'),
        success_rate=('success', 'mean'),
        epa_per_play=('epa', 'mean')
    )
    .reset_index()
)

# Order by game script
script_order = ["Winning big (10+)", "Winning (4-9)", "Close game",
                "Losing (4-9)", "Losing big (10+)"]
game_script_success['game_script'] = pd.Categorical(
    game_script_success['game_script'],
    categories=script_order,
    ordered=True
)
game_script_success = game_script_success.sort_values(['game_script', 'play_type'])

print("\nSuccess Rate by Game Script (2023 Regular Season):")
print(game_script_success.to_string(index=False))
print("\nNote: Game script based on score differential")

Game Planning Takeaways

1. **When winning**: Success rate stays high for running plays as defenses expect pass. Use this to control clock. 2. **When losing**: Passing maintains better success rate and EPA even when defenses expect it. 3. **Close games**: Both play types are viable; use situational factors (down, distance, field position) to decide. 4. **Third down**: Identify opponent weaknesses and exploit them with appropriate play calling.

Explosive Play Rate

While success rate measures consistency, explosive play rate measures the frequency of big plays:

$$ \text{Explosive Play Rate} = \frac{\text{Plays with EPA > 1.0}}{\text{Total Plays}} $$

Some analysts use different thresholds:
- EPA > 1.0: Highly valuable plays
- Yards > 20: Traditional "explosive" definition
- EPA > 1.5: Truly game-changing plays

#| label: explosive-rate-r
#| message: false
#| warning: false
#| cache: true

# Calculate explosive play rates
team_explosive <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(posteam),
    play_type %in% c("pass", "run")
  ) %>%
  group_by(posteam) %>%
  summarise(
    plays = n(),
    success_rate = mean(success, na.rm = TRUE),
    epa_per_play = mean(epa, na.rm = TRUE),
    explosive_rate_epa = mean(epa > 1.0, na.rm = TRUE),
    explosive_rate_yards = mean(yards_gained >= 20, na.rm = TRUE),
    huge_play_rate = mean(epa > 1.5, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  arrange(desc(explosive_rate_epa))

# Display top teams
team_explosive %>%
  head(10) %>%
  gt() %>%
  cols_label(
    posteam = "Team",
    plays = "Plays",
    success_rate = "Success Rate",
    epa_per_play = "EPA/Play",
    explosive_rate_epa = "Explosive Rate (EPA)",
    explosive_rate_yards = "Explosive Rate (Yards)",
    huge_play_rate = "Huge Play Rate"
  ) %>%
  fmt_percent(
    columns = c(success_rate, explosive_rate_epa,
                explosive_rate_yards, huge_play_rate),
    decimals = 1
  ) %>%
  fmt_number(
    columns = epa_per_play,
    decimals = 3
  ) %>%
  fmt_number(
    columns = plays,
    decimals = 0,
    use_seps = TRUE
  ) %>%
  tab_header(
    title = "Team Explosive Play Rates",
    subtitle = "2023 Regular Season, ranked by Explosive Rate (EPA)"
  ) %>%
  tab_source_note("Explosive (EPA) = EPA > 1.0; Explosive (Yards) = 20+ yards; Huge = EPA > 1.5")
#| label: explosive-rate-py
#| message: false
#| warning: false
#| cache: true

# Calculate explosive play rates
team_explosive = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & posteam.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .groupby('posteam')
    .agg(
        plays=('epa', 'count'),
        success_rate=('success', 'mean'),
        epa_per_play=('epa', 'mean'),
        explosive_rate_epa=('epa', lambda x: (x > 1.0).mean()),
        explosive_rate_yards=('yards_gained', lambda x: (x >= 20).mean()),
        huge_play_rate=('epa', lambda x: (x > 1.5).mean())
    )
    .reset_index()
    .sort_values('explosive_rate_epa', ascending=False)
)

print("\nTop 10 Teams by Explosive Play Rate (2023):")
print(team_explosive.head(10).to_string(index=False))
print("\nNote: Explosive (EPA) = EPA > 1.0; Explosive (Yards) = 20+ yards; Huge = EPA > 1.5")

Balancing Consistency and Explosiveness

Elite offenses excel at both consistency (high success rate) and explosiveness (high explosive play rate):

#| label: fig-consistency-explosiveness-r
#| fig-cap: "Success Rate vs Explosive Play Rate (2023)"
#| fig-width: 10
#| fig-height: 10
#| message: false
#| warning: false
#| cache: true

# Create quadrant plot
team_explosive %>%
  ggplot(aes(x = success_rate, y = explosive_rate_epa)) +
  geom_vline(xintercept = mean(team_explosive$success_rate),
             linetype = "dashed", alpha = 0.5) +
  geom_hline(yintercept = mean(team_explosive$explosive_rate_epa),
             linetype = "dashed", alpha = 0.5) +
  geom_nfl_logos(aes(team_abbr = posteam), width = 0.05, alpha = 0.8) +
  # Add quadrant labels
  annotate("text", x = 0.42, y = 0.15,
           label = "Elite:\nConsistent &\nExplosive",
           size = 4, fontface = "bold", color = "darkgreen") +
  annotate("text", x = 0.42, y = 0.08,
           label = "Grind It Out:\nConsistent,\nNot Explosive",
           size = 4, fontface = "bold", color = "orange") +
  annotate("text", x = 0.50, y = 0.15,
           label = "Boom or Bust:\nExplosive,\nNot Consistent",
           size = 4, fontface = "bold", color = "purple") +
  annotate("text", x = 0.50, y = 0.08,
           label = "Struggling:\nNeither",
           size = 4, fontface = "bold", color = "red") +
  scale_x_continuous(labels = percent_format(accuracy = 1)) +
  scale_y_continuous(labels = percent_format(accuracy = 1)) +
  labs(
    title = "The Complete Offensive Picture: Consistency vs Explosiveness",
    subtitle = "Success Rate vs Explosive Play Rate, 2023 Regular Season",
    x = "Success Rate (% plays with EPA > 0)",
    y = "Explosive Play Rate (% plays with EPA > 1.0)",
    caption = "Data: nflfastR | Top-right = Best offenses"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 16),
    plot.subtitle = element_text(size = 11),
    panel.grid.minor = element_blank()
  )
#| label: fig-consistency-explosiveness-py
#| fig-cap: "Success Rate vs Explosive Play Rate - Python (2023)"
#| fig-width: 10
#| fig-height: 10
#| message: false
#| warning: false
#| cache: true

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

# Add reference lines
mean_sr = team_explosive['success_rate'].mean()
mean_expl = team_explosive['explosive_rate_epa'].mean()
ax.axvline(x=mean_sr, linestyle='--', alpha=0.5, color='gray')
ax.axhline(y=mean_expl, linestyle='--', alpha=0.5, color='gray')

# Plot teams
for _, row in team_explosive.iterrows():
    team = row['posteam']
    color = team_colors.get(team, '#333333')
    ax.scatter(row['success_rate'], row['explosive_rate_epa'],
               s=300, color=color, alpha=0.6, edgecolors='white', linewidth=2)
    ax.text(row['success_rate'], row['explosive_rate_epa'], team,
            ha='center', va='center', fontsize=8, fontweight='bold')

# Add quadrant labels
ax.text(0.42, 0.15, 'Elite:\nConsistent &\nExplosive',
        fontsize=11, fontweight='bold', color='darkgreen', ha='center')
ax.text(0.42, 0.08, 'Grind It Out:\nConsistent,\nNot Explosive',
        fontsize=11, fontweight='bold', color='orange', ha='center')
ax.text(0.50, 0.15, 'Boom or Bust:\nExplosive,\nNot Consistent',
        fontsize=11, fontweight='bold', color='purple', ha='center')
ax.text(0.50, 0.08, 'Struggling:\nNeither',
        fontsize=11, fontweight='bold', color='red', ha='center')

# Format axes
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.0%}'))
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, p: f'{y:.0%}'))
ax.set_xlabel('Success Rate (% plays with EPA > 0)', fontsize=12)
ax.set_ylabel('Explosive Play Rate (% plays with EPA > 1.0)', fontsize=12)
ax.set_title('The Complete Offensive Picture: Consistency vs Explosiveness\nSuccess Rate vs Explosive Play Rate, 2023 Regular Season',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.99, 0.01, 'Data: nfl_data_py | Top-right = Best offenses',
        transform=ax.transAxes, ha='right', va='bottom', fontsize=8, style='italic')
ax.grid(alpha=0.3)

plt.tight_layout()
plt.show()

The Optimal Mix

#| label: optimal-mix-r
#| message: false
#| warning: false

# Calculate contribution of each to total EPA
contribution_analysis <- pbp %>%
  filter(
    season == 2023,
    season_type == "REG",
    !is.na(epa),
    !is.na(posteam),
    play_type %in% c("pass", "run")
  ) %>%
  group_by(posteam) %>%
  summarise(
    total_epa = sum(epa, na.rm = TRUE),
    # EPA from successful non-explosive plays (0 < EPA <= 1)
    consistent_epa = sum(epa[epa > 0 & epa <= 1.0], na.rm = TRUE),
    # EPA from explosive plays (EPA > 1)
    explosive_epa = sum(epa[epa > 1.0], na.rm = TRUE),
    # EPA from unsuccessful plays (EPA <= 0)
    negative_epa = sum(epa[epa <= 0], na.rm = TRUE),
    .groups = "drop"
  ) %>%
  mutate(
    pct_from_consistent = consistent_epa / (consistent_epa + explosive_epa),
    pct_from_explosive = explosive_epa / (consistent_epa + explosive_epa)
  ) %>%
  arrange(desc(total_epa))

cat("EPA Contribution Analysis:\n")
cat("Average % of positive EPA from consistent plays:",
    percent(mean(contribution_analysis$pct_from_consistent), accuracy = 0.1), "\n")
cat("Average % of positive EPA from explosive plays:",
    percent(mean(contribution_analysis$pct_from_explosive), accuracy = 0.1), "\n\n")

# Show top teams
contribution_analysis %>%
  head(10) %>%
  select(posteam, total_epa, pct_from_consistent, pct_from_explosive) %>%
  gt() %>%
  cols_label(
    posteam = "Team",
    total_epa = "Total EPA",
    pct_from_consistent = "% from Consistent",
    pct_from_explosive = "% from Explosive"
  ) %>%
  fmt_number(
    columns = total_epa,
    decimals = 1
  ) %>%
  fmt_percent(
    columns = c(pct_from_consistent, pct_from_explosive),
    decimals = 1
  ) %>%
  tab_header(
    title = "Sources of Offensive Value",
    subtitle = "Top 10 teams by total EPA, 2023"
  ) %>%
  tab_source_note("Consistent = 0 < EPA ≤ 1.0; Explosive = EPA > 1.0")
#| label: optimal-mix-py
#| message: false
#| warning: false

# Calculate contribution of each to total EPA
def categorize_epa(epa):
    if pd.isna(epa):
        return np.nan
    elif epa > 1.0:
        return 'explosive'
    elif epa > 0:
        return 'consistent'
    else:
        return 'negative'

contribution_data = (pbp
    .query("season == 2023 & season_type == 'REG' & epa.notna() & posteam.notna()")
    .query("play_type.isin(['pass', 'run'])")
    .copy()
)

contribution_data['epa_category'] = contribution_data['epa'].apply(categorize_epa)

contribution_analysis = (contribution_data
    .groupby(['posteam', 'epa_category'])
    .agg(category_epa=('epa', 'sum'))
    .reset_index()
    .pivot(index='posteam', columns='epa_category', values='category_epa')
    .fillna(0)
    .reset_index()
)

contribution_analysis['total_epa'] = (contribution_analysis['consistent'] +
                                      contribution_analysis['explosive'] +
                                      contribution_analysis['negative'])

contribution_analysis['pct_from_consistent'] = (
    contribution_analysis['consistent'] /
    (contribution_analysis['consistent'] + contribution_analysis['explosive'])
)

contribution_analysis['pct_from_explosive'] = (
    contribution_analysis['explosive'] /
    (contribution_analysis['consistent'] + contribution_analysis['explosive'])
)

contribution_analysis = contribution_analysis.sort_values('total_epa', ascending=False)

print("\nEPA Contribution Analysis:")
print(f"Average % of positive EPA from consistent plays: {contribution_analysis['pct_from_consistent'].mean():.1%}")
print(f"Average % of positive EPA from explosive plays: {contribution_analysis['pct_from_explosive'].mean():.1%}\n")

print("Sources of Offensive Value (Top 10 teams by total EPA, 2023):")
print(contribution_analysis[['posteam', 'total_epa', 'pct_from_consistent',
                             'pct_from_explosive']].head(10).to_string(index=False))
print("\nNote: Consistent = 0 < EPA ≤ 1.0; Explosive = EPA > 1.0")

The 60-40 Split

On average, offenses generate approximately: - **60% of their positive EPA** from consistent plays (success without explosion) - **40% of their positive EPA** from explosive plays However, elite offenses often have a higher explosive percentage, showing that big plays matter enormously. The key is to have both: - **High success rate**: Keeps drives alive, avoids punts - **Explosive plays**: Scores points quickly, puts pressure on defenses

Summary

In this chapter, we explored success rate and its relationship to offensive efficiency:

Key Concepts:

  • Success rate measures consistency using the 40-60-100 rule or EPA > 0
  • EPA measures total value created
  • Explosive play rate measures frequency of game-changing plays
  • Together, these metrics provide a complete picture of offensive performance

Important Findings:

  1. Success rate and EPA are highly correlated (r > 0.85) but measure different things
  2. Passing and running have similar success rates, but passing generates more EPA
  3. Success rates vary significantly by down, distance, and field position
  4. Elite offenses balance consistency (high success rate) and explosiveness
  5. Both metrics show moderate year-to-year stability

Practical Applications:

  • Use success rate to evaluate offensive consistency and sustainability
  • Combine with EPA to identify offensive archetypes (grinding vs explosive)
  • Analyze situational success rates to identify opponent weaknesses
  • Balance play-calling to maintain both consistency and explosiveness
  • Track explosive play rate as a leading indicator of offensive upside

Success rate complements EPA perfectly—together, they help us understand not just how much value an offense creates, but how reliably and explosively they create it.

Exercises

Conceptual Questions

  1. Success Definition: Explain why the 40-60-100 rule uses different thresholds for each down. How would success rates change if we used 50% for all downs?

  2. Consistency vs Explosiveness: A team has a 48% success rate but an EPA per play of 0.10 (above average). What does this tell you about their offensive style? Provide a real-world example of such a team.

  3. Predictive Value: If you had to choose one metric to predict next week's performance, would you choose success rate or EPA? Why? What about for predicting next season's performance?

  4. Game Planning: You're playing a defense that allows a 52% success rate but only a 9% explosive play rate. How should this inform your offensive strategy?

Coding Exercises

Exercise 1: Custom Success Metrics

Calculate team success rates using three different definitions: a) The 40-60-100 rule (yards-based) b) EPA > 0 c) Gaining at least 5 yards (constant threshold) Create a table comparing all three definitions for each team. Which definition shows the most variation across teams? Which is most correlated with actual team wins? **Hint**: Use `cor()` to calculate correlations with win percentage.

Exercise 2: Situation-Specific Success

Analyze success rates for the following specific situations: a) 3rd and short (1-3 yards) b) 3rd and medium (4-7 yards) c) 3rd and long (8+ yards) d) Red zone (inside the 20) e) Two-minute drill (last 2 minutes of half) For each situation, calculate success rate by play type (pass vs run). Create visualizations showing which play type is more successful in each situation.

Exercise 3: Consistency Profiles

For each team in 2023, calculate: a) Success rate b) Explosive play rate (EPA > 1.0) c) Huge play rate (EPA > 1.5) d) Disaster play rate (EPA < -1.0) e) Standard deviation of EPA (volatility) Create a team profile report that classifies each team into one of four archetypes: - Elite (high success, high explosive) - Consistent (high success, low explosive) - Boom-or-bust (low success, high explosive) - Struggling (low success, low explosive) Visualize these archetypes using a scatter plot with team logos.

Exercise 4: Play-Calling Analysis

For a single team of your choice, analyze their play-calling and success by: a) Down and distance b) Score differential c) Quarter d) Field position Calculate both the play distribution (% pass vs run) and success rate for each category. Create visualizations showing when they pass vs run and how successful each choice is. **Advanced**: Identify situations where the team's play-calling seems suboptimal (low success rate, predictable patterns).

Exercise 5: Temporal Trends

Analyze how team success rates change over the course of a season: a) Calculate rolling 4-game success rates for each team b) Identify teams that improved or declined most during the season c) Correlate early-season success rate (weeks 1-6) with late-season success rate (weeks 12-18) d) Create a visualization showing success rate trends over time for division rivals **Advanced**: Use regression to test if early-season success rate predicts late-season record better than early-season record alone.

Challenge Problem

Exercise 6: Comprehensive Team Evaluation Dashboard

Build a complete team evaluation tool that generates a report for any team including: **Section 1: Overall Metrics** - Success rate, EPA per play, explosive play rate - Rankings in each category - Comparison to league average **Section 2: Situational Breakdowns** - Success rate by down - Success rate by distance to go - Success rate by field position - Success rate by game script **Section 3: Consistency Analysis** - Week-by-week success rate trend - Success rate by quarter (fatigue analysis) - Comparison of first half vs second half performance **Section 4: Play Type Analysis** - Pass vs run success rates - Success rate by play type and situation - Explosive play contribution by play type **Section 5: Visualizations** - Success rate vs EPA quadrant chart - Consistency vs explosiveness profile - Weekly trend lines - Situational heat maps **Deliverable**: A function that takes a team abbreviation and season, and generates a multi-page PDF report with all analyses and visualizations. **Bonus**: Add defensive metrics (success rate allowed) and compare offensive vs defensive performance.

Further Reading

Academic Papers

  • Burke, B. (2019). "The Success Rate Metric in Football Analytics." Advanced Football Analytics.
  • Football Outsiders. (2023). "DVOA and Success Rate Methodology."

Industry Articles

  • Baldwin, B. (2021). "Understanding Success Rate and EPA Together." Open Source Football.
  • Robby Greer. (2022). "Consistency vs Explosiveness in NFL Offenses." Sports Info Solutions.

Books

  • Alamar, B. (2013). Sports Analytics: A Guide for Coaches, Managers, and Other Decision Makers. Columbia University Press.
  • Burke, B. (2020). The Numbers Game: Why Everything You Know About Football is Wrong. Penguin Books.

Online Resources

  • Open Source Football: https://www.opensourcefootball.com/
  • Football Outsiders: https://www.footballoutsiders.com/
  • nflfastR Documentation: https://www.nflfastr.com/

References

:::