Situational Football Analytics
Beginner
10 min read
0 views
Nov 27, 2025
# Situational Football Analytics
## Introduction
Situational football analytics focuses on critical game moments that disproportionately impact win probability: two-minute situations, fourth down decisions, red zone efficiency, and late-game clock management. Mastering these situations creates significant competitive advantages.
## Key Situations
### Critical Moments
- **Two-Minute Drill**: Pre-half and end-game drives
- **Fourth Down**: Go-for-it vs punt/FG decisions
- **Red Zone**: Scoring efficiency inside the 20
- **Goal-to-Go**: Short yardage touchdown situations
- **Late-Game**: Clock management when leading/trailing
### Decision Framework
- Win probability added (WPA)
- Expected points (EP) and EPA
- Success probability by situation
- Risk-reward tradeoffs
- Historical decision outcomes
## R Analysis with nflfastR
```r
library(nflfastR)
library(dplyr)
library(ggplot2)
library(tidyr)
# Load play-by-play data
pbp <- load_pbp(2023)
# Filter to relevant plays
plays <- pbp %>%
filter(!is.na(posteam), !is.na(epa))
# Two-Minute Drill Analysis
two_minute_drill <- plays %>%
filter(
(qtr == 2 & game_seconds_remaining <= 120 & game_seconds_remaining > 0) |
(qtr == 4 & game_seconds_remaining <= 120 & game_seconds_remaining > 0),
play_type %in% c("run", "pass")
) %>%
group_by(posteam, qtr) %>%
summarise(
plays = n(),
epa_per_play = mean(epa, na.rm = TRUE),
success_rate = mean(success, na.rm = TRUE),
points_scored = sum(touchdown == 1, na.rm = TRUE) * 7, # Simplified
.groups = "drop"
)
# Best two-minute offenses
two_min_summary <- two_minute_drill %>%
group_by(posteam) %>%
summarise(
total_plays = sum(plays),
avg_epa = weighted.mean(epa_per_play, plays),
avg_success = weighted.mean(success_rate, plays),
total_points = sum(points_scored),
.groups = "drop"
) %>%
arrange(desc(avg_epa)) %>%
head(10)
print("Top 10 Two-Minute Drill Offenses:")
print(two_min_summary)
# Visualize two-minute performance
ggplot(two_min_summary, aes(x = reorder(posteam, avg_epa), y = avg_epa)) +
geom_col(fill = "darkblue", alpha = 0.7) +
geom_text(aes(label = paste0(total_plays, " plays")),
hjust = -0.1, size = 3) +
coord_flip() +
labs(
title = "Two-Minute Drill Efficiency - 2023 NFL",
x = "Team",
y = "EPA per Play",
subtitle = "Final 2 minutes of each half"
) +
theme_minimal()
# Fourth Down Decision Analysis
fourth_downs <- plays %>%
filter(down == 4) %>%
mutate(
decision = case_when(
play_type == "punt" ~ "Punt",
field_goal_attempt == 1 ~ "Field Goal",
play_type %in% c("run", "pass") ~ "Go For It",
TRUE ~ "Other"
),
distance_category = case_when(
ydstogo <= 2 ~ "Short (1-2)",
ydstogo <= 5 ~ "Medium (3-5)",
ydstogo <= 10 ~ "Long (6-10)",
TRUE ~ "Very Long (11+)"
),
field_position = case_when(
yardline_100 <= 35 ~ "Opponent Territory",
yardline_100 <= 50 ~ "Midfield",
TRUE ~ "Own Territory"
)
) %>%
filter(decision != "Other")
# Fourth down go-for-it success
fourth_down_go <- fourth_downs %>%
filter(decision == "Go For It") %>%
group_by(distance_category, field_position) %>%
summarise(
attempts = n(),
success_rate = mean(first_down == 1 | touchdown == 1, na.rm = TRUE),
avg_wpa = mean(wpa, na.rm = TRUE),
avg_epa = mean(epa, na.rm = TRUE),
.groups = "drop"
) %>%
filter(attempts >= 10)
print("\nFourth Down Conversion Rates (Go For It):")
print(fourth_down_go)
# Fourth down decision frequency
fourth_down_decisions <- fourth_downs %>%
group_by(decision, distance_category, field_position) %>%
summarise(decisions = n(), .groups = "drop") %>%
group_by(distance_category, field_position) %>%
mutate(
total_decisions = sum(decisions),
decision_rate = decisions / total_decisions * 100
)
# Most aggressive fourth down teams
aggressive_teams <- fourth_downs %>%
group_by(posteam, decision) %>%
summarise(decisions = n(), .groups = "drop") %>%
group_by(posteam) %>%
mutate(
total_4th_downs = sum(decisions),
decision_rate = decisions / total_4th_downs * 100
) %>%
filter(decision == "Go For It", total_4th_downs >= 20) %>%
arrange(desc(decision_rate)) %>%
head(10)
print("\nMost Aggressive 4th Down Teams (Go-For-It Rate):")
print(aggressive_teams)
# Visualize fourth down aggression
ggplot(aggressive_teams, aes(x = reorder(posteam, decision_rate),
y = decision_rate)) +
geom_col(fill = "darkred", alpha = 0.7) +
geom_text(aes(label = paste0(round(decision_rate, 1), "%")),
hjust = -0.1, size = 3.5) +
coord_flip() +
labs(
title = "Most Aggressive 4th Down Teams - 2023",
x = "Team",
y = "Go-For-It Rate (%)",
subtitle = "Percentage of 4th downs where team attempts conversion"
) +
theme_minimal()
# Red Zone Efficiency Analysis
red_zone <- plays %>%
filter(
yardline_100 <= 20,
play_type %in% c("run", "pass")
) %>%
group_by(posteam) %>%
summarise(
plays = n(),
epa_per_play = mean(epa, na.rm = TRUE),
success_rate = mean(success, na.rm = TRUE),
td_rate = mean(touchdown == 1, na.rm = TRUE),
.groups = "drop"
) %>%
filter(plays >= 50) %>%
arrange(desc(td_rate))
print("\nTop 10 Red Zone Offenses (TD Rate):")
print(red_zone %>% head(10))
# Red zone pass vs run
red_zone_split <- plays %>%
filter(yardline_100 <= 20, play_type %in% c("run", "pass")) %>%
group_by(posteam, play_type) %>%
summarise(
plays = n(),
epa_per_play = mean(epa, na.rm = TRUE),
td_rate = mean(touchdown == 1, na.rm = TRUE),
.groups = "drop"
)
red_zone_comparison <- red_zone_split %>%
pivot_wider(
names_from = play_type,
values_from = c(plays, epa_per_play, td_rate)
) %>%
mutate(
pass_rate = plays_pass / (plays_pass + plays_run) * 100,
pass_td_advantage = td_rate_pass - td_rate_run
) %>%
arrange(desc(pass_td_advantage))
print("\nRed Zone Pass vs Run TD Rate Advantage:")
print(red_zone_comparison %>%
select(posteam, pass_rate, td_rate_pass, td_rate_run, pass_td_advantage) %>%
head(10))
# Visualize red zone efficiency
ggplot(red_zone %>% head(15),
aes(x = success_rate, y = td_rate)) +
geom_point(aes(size = plays), alpha = 0.7, color = "darkgreen") +
geom_text(aes(label = posteam), vjust = -1, size = 3) +
labs(
title = "Red Zone Efficiency - Success Rate vs TD Rate",
x = "Success Rate",
y = "TD Rate (per play)",
size = "Plays",
subtitle = "Top 15 red zone offenses (2023)"
) +
theme_minimal()
# Goal-to-Go Analysis (1st & Goal)
goal_to_go <- plays %>%
filter(
down == 1,
yardline_100 <= 10,
play_type %in% c("run", "pass")
) %>%
group_by(posteam, play_type) %>%
summarise(
plays = n(),
td_rate = mean(touchdown == 1, na.rm = TRUE),
avg_yards = mean(yards_gained, na.rm = TRUE),
.groups = "drop"
)
goal_to_go_wide <- goal_to_go %>%
pivot_wider(
names_from = play_type,
values_from = c(plays, td_rate, avg_yards)
) %>%
mutate(total_plays = plays_pass + plays_run,
pass_rate = plays_pass / total_plays * 100) %>%
filter(total_plays >= 10)
print("\nGoal-to-Go (1st & Goal) Performance:")
print(goal_to_go_wide %>%
select(posteam, pass_rate, td_rate_pass, td_rate_run) %>%
arrange(desc(td_rate_pass)) %>%
head(10))
# Late-game clock management
late_game_leading <- plays %>%
filter(
qtr == 4,
game_seconds_remaining <= 300,
score_differential >= 1,
score_differential <= 8,
play_type %in% c("run", "pass")
) %>%
group_by(posteam, play_type) %>%
summarise(
plays = n(),
win_rate = mean(result == "W", na.rm = TRUE),
.groups = "drop"
) %>%
group_by(posteam) %>%
mutate(
total_plays = sum(plays),
play_rate = plays / total_plays * 100
) %>%
filter(play_type == "run", total_plays >= 20)
print("\nLate Game Clock Management (Leading by 1-8 points):")
print(late_game_leading %>%
select(posteam, play_rate, win_rate, total_plays) %>%
arrange(desc(win_rate)) %>%
head(10))
# Situational win probability
situational_wp <- plays %>%
filter(!is.na(wp), !is.na(wpa)) %>%
mutate(
situation = case_when(
down == 4 ~ "4th Down",
yardline_100 <= 10 & down == 1 ~ "Goal-to-Go",
yardline_100 <= 20 ~ "Red Zone",
qtr >= 4 & game_seconds_remaining <= 120 ~ "Two-Minute",
TRUE ~ "Standard"
)
) %>%
group_by(situation) %>%
summarise(
plays = n(),
avg_wpa = mean(wpa, na.rm = TRUE),
avg_epa = mean(epa, na.rm = TRUE),
.groups = "drop"
)
print("\nAverage WPA by Situation:")
print(situational_wp)
# Visualize situational importance
ggplot(situational_wp, aes(x = reorder(situation, abs(avg_wpa)),
y = abs(avg_wpa))) +
geom_col(fill = "purple", alpha = 0.7) +
coord_flip() +
labs(
title = "Situational Importance - Average Win Probability Added",
x = "Situation",
y = "Absolute Average WPA",
subtitle = "Higher values = more impact on game outcome"
) +
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 plays
plays = pbp[pbp['epa'].notna()].copy()
# Two-Minute Drill Analysis
two_minute = plays[
((plays['qtr'] == 2) | (plays['qtr'] == 4)) &
(plays['game_seconds_remaining'] <= 120) &
(plays['game_seconds_remaining'] > 0) &
(plays['play_type'].isin(['run', 'pass']))
].copy()
two_min_summary = two_minute.groupby('posteam').agg({
'play_id': 'count',
'epa': 'mean',
'success': 'mean',
'touchdown': 'sum'
}).rename(columns={
'play_id': 'plays',
'epa': 'epa_per_play',
'success': 'success_rate',
'touchdown': 'touchdowns'
})
two_min_summary = two_min_summary[two_min_summary['plays'] >= 30].sort_values(
'epa_per_play', ascending=False
)
print("Top 10 Two-Minute Drill Offenses:")
print(two_min_summary.head(10))
# Fourth Down Analysis
fourth_downs = plays[plays['down'] == 4].copy()
fourth_downs['decision'] = fourth_downs.apply(lambda row:
'Punt' if row['play_type'] == 'punt' else
'Field Goal' if row['field_goal_attempt'] == 1 else
'Go For It' if row['play_type'] in ['run', 'pass'] else 'Other',
axis=1
)
fourth_downs = fourth_downs[fourth_downs['decision'] != 'Other']
# Fourth down go-for-it success
fourth_go = fourth_downs[fourth_downs['decision'] == 'Go For It'].copy()
fourth_go['converted'] = (fourth_go['first_down'] == 1) | (fourth_go['touchdown'] == 1)
fourth_go['distance_cat'] = pd.cut(
fourth_go['ydstogo'],
bins=[0, 2, 5, 10, 100],
labels=['Short (1-2)', 'Medium (3-5)', 'Long (6-10)', 'Very Long (11+)']
)
conversion_rates = fourth_go.groupby('distance_cat').agg({
'play_id': 'count',
'converted': 'mean',
'wpa': 'mean',
'epa': 'mean'
}).rename(columns={'play_id': 'attempts', 'converted': 'success_rate'})
print("\nFourth Down Conversion Rates:")
print(conversion_rates)
# Most aggressive fourth down teams
team_4th_aggression = fourth_downs.groupby(['posteam', 'decision']).size().reset_index(name='count')
team_4th_aggression['total'] = team_4th_aggression.groupby('posteam')['count'].transform('sum')
team_4th_aggression['rate'] = team_4th_aggression['count'] / team_4th_aggression['total'] * 100
aggressive_teams = team_4th_aggression[
(team_4th_aggression['decision'] == 'Go For It') &
(team_4th_aggression['total'] >= 20)
].sort_values('rate', ascending=False)
print("\nMost Aggressive 4th Down Teams:")
print(aggressive_teams.head(10))
# Red Zone Analysis
red_zone = plays[
(plays['yardline_100'] <= 20) &
(plays['play_type'].isin(['run', 'pass']))
].copy()
red_zone_summary = red_zone.groupby('posteam').agg({
'play_id': 'count',
'epa': 'mean',
'success': 'mean',
'touchdown': lambda x: (x == 1).mean()
}).rename(columns={
'play_id': 'plays',
'epa': 'epa_per_play',
'success': 'success_rate',
'touchdown': 'td_rate'
})
red_zone_summary = red_zone_summary[red_zone_summary['plays'] >= 50].sort_values(
'td_rate', ascending=False
)
print("\nTop 10 Red Zone Offenses (TD Rate):")
print(red_zone_summary.head(10))
# Red zone pass vs run
red_zone_split = red_zone.groupby(['posteam', 'play_type']).agg({
'play_id': 'count',
'epa': 'mean',
'touchdown': lambda x: (x == 1).mean()
}).reset_index()
red_zone_split.columns = ['team', 'play_type', 'plays', 'epa', 'td_rate']
red_zone_comparison = red_zone_split.pivot(
index='team', columns='play_type', values=['plays', 'epa', 'td_rate']
)
red_zone_comparison.columns = ['_'.join(col) for col in red_zone_comparison.columns]
red_zone_comparison['pass_rate'] = (
red_zone_comparison['plays_pass'] /
(red_zone_comparison['plays_pass'] + red_zone_comparison['plays_run']) * 100
)
red_zone_comparison['pass_td_advantage'] = (
red_zone_comparison['td_rate_pass'] - red_zone_comparison['td_rate_run']
)
red_zone_comparison = red_zone_comparison.sort_values('pass_td_advantage', ascending=False)
print("\nRed Zone Pass TD Advantage:")
print(red_zone_comparison[['pass_rate', 'td_rate_pass', 'td_rate_run',
'pass_td_advantage']].head(10))
# Goal-to-go analysis
goal_to_go = plays[
(plays['down'] == 1) &
(plays['yardline_100'] <= 10) &
(plays['play_type'].isin(['run', 'pass']))
].copy()
gtg_summary = goal_to_go.groupby(['posteam', 'play_type']).agg({
'play_id': 'count',
'touchdown': lambda x: (x == 1).mean(),
'yards_gained': 'mean'
}).reset_index()
gtg_summary.columns = ['team', 'play_type', 'plays', 'td_rate', 'avg_yards']
gtg_comparison = gtg_summary.pivot(
index='team', columns='play_type', values=['plays', 'td_rate']
)
gtg_comparison.columns = ['_'.join(col) for col in gtg_comparison.columns]
gtg_comparison = gtg_comparison.reset_index()
gtg_comparison['total_plays'] = (
gtg_comparison['plays_pass'] + gtg_comparison['plays_run']
)
gtg_comparison = gtg_comparison[gtg_comparison['total_plays'] >= 10].sort_values(
'td_rate_pass', ascending=False
)
print("\nGoal-to-Go (1st & Goal) TD Rates:")
print(gtg_comparison[['team', 'td_rate_pass', 'td_rate_run']].head(10))
# Visualizations
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
# 1. Two-minute drill performance
top_2min = two_min_summary.head(12)
axes[0, 0].barh(range(len(top_2min)), top_2min['epa_per_play'],
color='darkblue', alpha=0.7)
axes[0, 0].set_yticks(range(len(top_2min)))
axes[0, 0].set_yticklabels(top_2min.index, fontsize=9)
axes[0, 0].set_xlabel('EPA per Play')
axes[0, 0].set_title('Two-Minute Drill Efficiency - Top 12 Teams')
axes[0, 0].invert_yaxis()
# 2. Fourth down aggression
top_aggressive = aggressive_teams.head(12)
axes[0, 1].barh(range(len(top_aggressive)), top_aggressive['rate'],
color='darkred', alpha=0.7)
axes[0, 1].set_yticks(range(len(top_aggressive)))
axes[0, 1].set_yticklabels(top_aggressive['posteam'], fontsize=9)
axes[0, 1].set_xlabel('Go-For-It Rate (%)')
axes[0, 1].set_title('Most Aggressive 4th Down Teams')
axes[0, 1].invert_yaxis()
# 3. Red zone TD rate
top_rz = red_zone_summary.head(12)
axes[1, 0].barh(range(len(top_rz)), top_rz['td_rate'] * 100,
color='darkgreen', alpha=0.7)
axes[1, 0].set_yticks(range(len(top_rz)))
axes[1, 0].set_yticklabels(top_rz.index, fontsize=9)
axes[1, 0].set_xlabel('TD Rate (%)')
axes[1, 0].set_title('Red Zone TD Rate - Top 12 Offenses')
axes[1, 0].invert_yaxis()
# 4. Fourth down conversion by distance
axes[1, 1].bar(range(len(conversion_rates)), conversion_rates['success_rate'] * 100,
color='steelblue', alpha=0.7)
axes[1, 1].set_xticks(range(len(conversion_rates)))
axes[1, 1].set_xticklabels(conversion_rates.index, rotation=45, ha='right')
axes[1, 1].set_ylabel('Conversion Rate (%)')
axes[1, 1].set_title('4th Down Conversion Rate by Distance')
axes[1, 1].axhline(50, color='red', linestyle='--', alpha=0.5)
plt.tight_layout()
plt.show()
```
## Key Insights
### Critical Situations
- **Two-Minute**: Pass-heavy approach (75%+ pass rate) most effective
- **Fourth Down**: Go-for-it on 4th & 3 or less in opponent territory
- **Red Zone**: Pass plays generate higher TD rate inside 10-yard line
- **Goal-to-Go**: Heavy personnel packages most successful
### Decision Making
- Analytics-based fourth down decisions increase win probability
- Clock management crucial in late-game situations
- Red zone efficiency separates elite from average offenses
- Two-minute drill execution strong predictor of playoff success
## Resources
- [nflfastR documentation](https://www.nflfastr.com/)
- [Fourth Down Calculator](https://sports.sites.yale.edu/4th-down-calculator)
- [Ben Baldwin's Analysis](https://www.opensourcefootball.com/)
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Related Topics
Quick Actions