Offensive Scheme Analytics
Beginner
10 min read
1 views
Nov 27, 2025
# Offensive Scheme Analytics
## Introduction
Offensive scheme analytics involves quantifying play design effectiveness, formation tendencies, and overall system efficiency. Modern analytics allows coaches and analysts to evaluate which schemes maximize scoring potential and exploit defensive weaknesses.
## Core Offensive Concepts
### Scheme Types
- **Spread Offense**: Horizontal spacing, tempo, quick passes
- **West Coast**: Short passing, timing routes, YAC focus
- **Air Raid**: Vertical passing, high volume, simplified reads
- **Power Run**: Gap scheme, physical blocking, play action
- **Zone Run**: Outside zone, inside zone, bootlegs
### Key Metrics
- **EPA per Play**: Expected points added per offensive play
- **Success Rate**: Percentage of plays gaining positive EPA
- **Explosive Play Rate**: Plays gaining 15+ yards (pass) or 10+ yards (run)
- **Yards per Play**: Average yards gained per play
- **Points per Drive**: Scoring efficiency
## R Analysis with nflfastR
```r
library(nflfastR)
library(dplyr)
library(ggplot2)
library(tidyr)
# Load play-by-play data
pbp <- load_pbp(2023)
# Filter to regular plays
plays <- pbp %>%
filter(
!is.na(posteam),
!is.na(epa),
play_type %in% c("run", "pass")
)
# Analyze offensive scheme efficiency by formation
formation_analysis <- plays %>%
mutate(
personnel_grouped = case_when(
grepl("1 RB", personnel) ~ "11 Personnel (1 RB, 1 TE)",
grepl("2 RB", personnel) ~ "12 Personnel (1 RB, 2 TE)",
grepl("3 WR", personnel) & grepl("0 TE", personnel) ~ "10 Personnel (1 RB, 0 TE)",
TRUE ~ "Other"
)
) %>%
filter(personnel_grouped != "Other") %>%
group_by(posteam, personnel_grouped) %>%
summarise(
plays = n(),
epa_per_play = mean(epa, na.rm = TRUE),
success_rate = mean(success, na.rm = TRUE),
explosive_rate = mean(yards_gained >= 15, na.rm = TRUE),
.groups = "drop"
)
# Top offenses by personnel grouping
top_teams_11 <- formation_analysis %>%
filter(personnel_grouped == "11 Personnel (1 RB, 1 TE)", plays >= 300) %>%
arrange(desc(epa_per_play)) %>%
head(10)
print("Top 10 Offenses in 11 Personnel (EPA/play):")
print(top_teams_11)
# Visualize formation efficiency
ggplot(formation_analysis %>% filter(plays >= 200),
aes(x = success_rate, y = epa_per_play, color = personnel_grouped)) +
geom_point(aes(size = plays), alpha = 0.6) +
geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
scale_color_manual(values = c("darkgreen", "blue", "orange")) +
labs(
title = "Offensive Scheme Efficiency by Personnel Grouping - 2023",
x = "Success Rate",
y = "EPA per Play",
size = "Total Plays",
color = "Personnel"
) +
theme_minimal()
# Play-action vs standard dropback analysis
play_action <- plays %>%
filter(play_type == "pass") %>%
mutate(
is_play_action = ifelse(grepl("play action", desc, ignore.case = TRUE),
"Play Action", "Standard Dropback")
) %>%
group_by(posteam, is_play_action) %>%
summarise(
plays = n(),
epa_per_play = mean(epa, na.rm = TRUE),
success_rate = mean(success, na.rm = TRUE),
yards_per_attempt = mean(yards_gained, na.rm = TRUE),
.groups = "drop"
)
# Compare play action efficiency
pa_comparison <- play_action %>%
filter(plays >= 50) %>%
pivot_wider(
names_from = is_play_action,
values_from = c(plays, epa_per_play, success_rate, yards_per_attempt)
) %>%
mutate(
pa_advantage = `epa_per_play_Play Action` - `epa_per_play_Standard Dropback`,
pa_usage_rate = `plays_Play Action` / (`plays_Play Action` + `plays_Standard Dropback`) * 100
) %>%
arrange(desc(pa_advantage))
print("\nTop 10 Teams with Biggest Play Action Advantage:")
print(pa_comparison %>%
select(posteam, pa_advantage, pa_usage_rate,
`epa_per_play_Play Action`, `epa_per_play_Standard Dropback`) %>%
head(10))
# Visualize play action advantage
ggplot(pa_comparison, aes(x = pa_usage_rate, y = pa_advantage)) +
geom_point(aes(size = `plays_Play Action`), alpha = 0.6, color = "darkblue") +
geom_smooth(method = "lm", se = TRUE, color = "red", linetype = "dashed") +
geom_hline(yintercept = 0, linetype = "solid", color = "gray") +
labs(
title = "Play Action Usage vs EPA Advantage - 2023",
x = "Play Action Usage Rate (%)",
y = "Play Action EPA Advantage (vs Standard Dropback)",
size = "PA Plays"
) +
theme_minimal()
# Run scheme analysis: outside vs inside zone
run_scheme <- plays %>%
filter(play_type == "run", !is.na(run_gap)) %>%
mutate(
zone_type = case_when(
run_gap %in% c("end", "edge") ~ "Outside Zone",
run_gap %in% c("guard", "tackle") ~ "Inside Zone",
run_gap == "middle" ~ "Inside Zone",
TRUE ~ "Other"
)
) %>%
filter(zone_type != "Other") %>%
group_by(posteam, zone_type) %>%
summarise(
carries = n(),
epa_per_carry = mean(epa, na.rm = TRUE),
yards_per_carry = mean(yards_gained, na.rm = TRUE),
success_rate = mean(success, na.rm = TRUE),
explosive_rate = mean(yards_gained >= 10, na.rm = TRUE),
.groups = "drop"
)
# Best outside zone teams
top_oz <- run_scheme %>%
filter(zone_type == "Outside Zone", carries >= 100) %>%
arrange(desc(epa_per_carry)) %>%
head(10)
print("\nTop 10 Outside Zone Running Teams (EPA/carry):")
print(top_oz)
# Best inside zone teams
top_iz <- run_scheme %>%
filter(zone_type == "Inside Zone", carries >= 100) %>%
arrange(desc(epa_per_carry)) %>%
head(10)
print("\nTop 10 Inside Zone Running Teams (EPA/carry):")
print(top_iz)
# Visualize run scheme effectiveness
ggplot(run_scheme %>% filter(carries >= 80),
aes(x = yards_per_carry, y = success_rate, color = zone_type)) +
geom_point(aes(size = carries), alpha = 0.6) +
scale_color_manual(values = c("Inside Zone" = "darkgreen",
"Outside Zone" = "blue")) +
labs(
title = "Run Scheme Effectiveness - Inside vs Outside Zone",
x = "Yards per Carry",
y = "Success Rate",
size = "Carries",
color = "Scheme"
) +
theme_minimal()
```
## Python Implementation
```python
import nfl_data_py as nfl
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# Load play-by-play data
pbp = nfl.import_pbp_data([2023])
# Filter to offensive plays
plays = pbp[
(pbp['play_type'].isin(['run', 'pass'])) &
(pbp['epa'].notna())
].copy()
# Offensive scheme efficiency by down and distance
def categorize_down_distance(row):
"""Categorize play situation"""
if row['down'] == 1:
return '1st Down'
elif row['down'] == 2 and row['ydstogo'] <= 7:
return '2nd & Short'
elif row['down'] == 2 and row['ydstogo'] > 7:
return '2nd & Long'
elif row['down'] == 3 and row['ydstogo'] <= 3:
return '3rd & Short'
elif row['down'] == 3 and row['ydstogo'] <= 7:
return '3rd & Medium'
elif row['down'] == 3 and row['ydstogo'] > 7:
return '3rd & Long'
else:
return 'Other'
plays['situation'] = plays.apply(categorize_down_distance, axis=1)
# Scheme efficiency by situation
situation_efficiency = plays.groupby(['posteam', 'situation', 'play_type']).agg({
'epa': ['count', 'mean'],
'success': 'mean',
'yards_gained': 'mean'
}).reset_index()
situation_efficiency.columns = ['team', 'situation', 'play_type',
'plays', 'epa_per_play', 'success_rate',
'yards_per_play']
# Filter for meaningful sample sizes
situation_efficiency = situation_efficiency[situation_efficiency['plays'] >= 30]
# Best offenses on 1st down
first_down_offense = situation_efficiency[
situation_efficiency['situation'] == '1st Down'
].groupby('team').agg({
'epa_per_play': 'mean',
'success_rate': 'mean'
}).sort_values('epa_per_play', ascending=False)
print("Top 10 Offenses on 1st Down (EPA/play):")
print(first_down_offense.head(10))
# Third down conversion analysis
third_downs = plays[plays['down'] == 3].copy()
third_down_performance = third_downs.groupby('posteam').agg({
'third_down_converted': 'mean',
'epa': 'mean',
'play_id': 'count'
}).rename(columns={
'third_down_converted': 'conversion_rate',
'epa': 'epa_per_play',
'play_id': 'attempts'
})
third_down_performance = third_down_performance[
third_down_performance['attempts'] >= 100
].sort_values('conversion_rate', ascending=False)
print("\nTop 10 Third Down Offenses (Conversion Rate):")
print(third_down_performance.head(10))
# Pass vs run balance analysis
team_balance = plays.groupby(['posteam', 'play_type']).agg({
'play_id': 'count',
'epa': 'mean',
'success': 'mean'
}).reset_index()
team_balance.columns = ['team', 'play_type', 'plays', 'epa_per_play', 'success_rate']
# Calculate pass/run ratio
balance_wide = team_balance.pivot(index='team', columns='play_type',
values=['plays', 'epa_per_play'])
balance_wide.columns = ['_'.join(col) for col in balance_wide.columns]
balance_wide['pass_rate'] = (
balance_wide['plays_pass'] /
(balance_wide['plays_pass'] + balance_wide['plays_run']) * 100
)
balance_wide['offensive_epa'] = (
balance_wide['epa_per_play_pass'] * balance_wide['plays_pass'] +
balance_wide['epa_per_play_run'] * balance_wide['plays_run']
) / (balance_wide['plays_pass'] + balance_wide['plays_run'])
balance_wide = balance_wide.reset_index().sort_values('offensive_epa', ascending=False)
print("\nOffensive Balance and Efficiency:")
print(balance_wide[['team', 'pass_rate', 'epa_per_play_pass',
'epa_per_play_run', 'offensive_epa']].head(15))
# Visualizations
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
# 1. Pass vs run EPA efficiency
axes[0, 0].scatter(balance_wide['epa_per_play_run'],
balance_wide['epa_per_play_pass'],
s=100, alpha=0.6, c=balance_wide['pass_rate'],
cmap='coolwarm')
axes[0, 0].axhline(0, color='black', linestyle='--', alpha=0.3)
axes[0, 0].axvline(0, color='black', linestyle='--', alpha=0.3)
axes[0, 0].set_xlabel('EPA per Run Play')
axes[0, 0].set_ylabel('EPA per Pass Play')
axes[0, 0].set_title('Pass vs Run Efficiency (color = pass rate)')
axes[0, 0].grid(alpha=0.3)
# 2. Third down conversion rates
top_3rd_down = third_down_performance.head(15)
axes[0, 1].barh(range(len(top_3rd_down)),
top_3rd_down['conversion_rate'] * 100,
color='darkgreen', alpha=0.7)
axes[0, 1].set_yticks(range(len(top_3rd_down)))
axes[0, 1].set_yticklabels(top_3rd_down.index, fontsize=9)
axes[0, 1].set_xlabel('3rd Down Conversion Rate (%)')
axes[0, 1].set_title('Top 15 Third Down Offenses - 2023')
axes[0, 1].axvline(40, color='red', linestyle='--', alpha=0.5, label='League Avg')
axes[0, 1].invert_yaxis()
axes[0, 1].legend()
# 3. Pass rate vs offensive EPA
axes[1, 0].scatter(balance_wide['pass_rate'], balance_wide['offensive_epa'],
s=100, alpha=0.6, color='steelblue')
axes[1, 0].set_xlabel('Pass Rate (%)')
axes[1, 0].set_ylabel('Offensive EPA per Play')
axes[1, 0].set_title('Pass Rate vs Overall Offensive Efficiency')
axes[1, 0].axhline(0, color='red', linestyle='--', alpha=0.5)
axes[1, 0].grid(alpha=0.3)
# 4. Situation efficiency heatmap
situation_pivot = situation_efficiency[
situation_efficiency['situation'].isin(['1st Down', '2nd & Short',
'2nd & Long', '3rd & Short',
'3rd & Medium', '3rd & Long'])
].groupby(['situation', 'play_type'])['epa_per_play'].mean().reset_index()
situation_heatmap = situation_pivot.pivot(
index='situation', columns='play_type', values='epa_per_play'
)
sns.heatmap(situation_heatmap, annot=True, fmt='.3f', cmap='RdYlGn',
center=0, ax=axes[1, 1], cbar_kws={'label': 'EPA per Play'})
axes[1, 1].set_title('Average EPA by Situation and Play Type')
axes[1, 1].set_xlabel('Play Type')
axes[1, 1].set_ylabel('Situation')
plt.tight_layout()
plt.show()
```
## Key Insights
### Scheme Efficiency Patterns
- **11 Personnel Dominance**: Most efficient formation in modern NFL
- **Play Action Advantage**: +0.15 EPA boost over standard dropbacks
- **Outside Zone Effectiveness**: Higher explosive play rate than inside zone
- **Third Down Success**: Pass-heavy offenses convert at higher rates
### Optimal Tendencies
- First down: Balanced attack maximizes second down success
- Second and short: Run success sets up play action
- Third and medium: Pass rate approaches 80-90%
- Red zone: Personnel mismatches more valuable than play type
## Resources
- [nflfastR documentation](https://www.nflfastr.com/)
- [NFL Next Gen Stats](https://nextgenstats.nfl.com/)
- [Pro Football Focus](https://www.pff.com/)
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Related Topics
Quick Actions