This project explores NFL play-calling strategies through a detailed analysis of play-by-play data from the 2022 season. We developed a custom play entropy metric to quantify variability in play-calling. This metric allows us to assess how unpredictable teams are in their play selection and to evaluate how this unpredictability correlates with offensive efficiency, as measured by Expected Points Added (EPA).
Additionally, we created an interactive Shiny application to visualize play probabilities by team, down, and distance. This app provides an intuitive way to explore team tendencies and situational decision-making. By examining league-wide trends and individual team strategies, this project sheds light on the relationship between play variability and offensive success, offering valuable insights for football analytics and decision-making.
games <- fread("NFL Big Data Bowl 2025/games.csv")
player_play <- fread("NFL Big Data Bowl 2025/player_play.csv")
players <- fread("NFL Big Data Bowl 2025/players.csv")
plays <- fread("NFL Big Data Bowl 2025/plays.csv")
# Load play-by-play data for the 2022 season
pbp_2022 <- load_pbp(2022)
# Load roster data for the 2022 season
roster_2022 <- fast_scraper_roster(2022)
# Merge play-by-play data with roster data for player positions
pbp_2022 <- pbp_2022 %>%
# Join passer position
left_join(roster_2022 %>% select(gsis_id, position) %>% rename(passer_player_id = gsis_id, passer_position = position),
by = "passer_player_id") %>%
# Join rusher position
left_join(roster_2022 %>% select(gsis_id, position) %>% rename(rusher_player_id = gsis_id, rusher_position = position),
by = "rusher_player_id") %>%
# Join receiver position
left_join(roster_2022 %>% select(gsis_id, position) %>% rename(receiver_player_id = gsis_id, receiver_position = position),
by = "receiver_player_id")
# Add a prefix to all column names in the pbp_2022 dataframe
colnames(pbp_2022) <- paste0("nflfastr_", colnames(pbp_2022))
# Ensure playId and gameId in plays are characters
plays <- plays %>%
mutate(gameId = as.character(gameId), playId = as.character(playId))
# Ensure nflfastr_old_game_id and nflfastr_play_id in pbp_2022 are characters
pbp_2022 <- pbp_2022 %>%
mutate(nflfastr_old_game_id = as.character(nflfastr_old_game_id),
nflfastr_play_id = as.character(nflfastr_play_id))
# Perform the join
plays_2022_master <- plays %>%
left_join(pbp_2022, by = c("gameId" = "nflfastr_old_game_id", "playId" = "nflfastr_play_id"))
plays_2022_master <- plays_2022_master %>%
mutate(
play_category = case_when(
# QB Sneaks
nflfastr_play_type == "run" & qbSneak == TRUE & nflfastr_rusher_position == "QB" ~ "qb_sneak",
# Non-QB Sneaks (WR, RB, TE)
nflfastr_play_type == "run" & qbSneak == TRUE & nflfastr_rusher_position %in% c("WR", "RB", "TE") ~ "nonQB_sneak",
# RB runs
nflfastr_play_type == "run" & nflfastr_rusher_position == "RB" & nflfastr_run_location == "left" ~ "rb_run_left",
nflfastr_play_type == "run" & nflfastr_rusher_position == "RB" & nflfastr_run_location == "middle" ~ "rb_run_middle",
nflfastr_play_type == "run" & nflfastr_rusher_position == "RB" & nflfastr_run_location == "right" ~ "rb_run_right",
# QB runs (general cases)
nflfastr_play_type == "run" & nflfastr_rusher_position == "QB" & nflfastr_run_location == "left" ~ "qb_run_left",
nflfastr_play_type == "run" & nflfastr_rusher_position == "QB" & nflfastr_run_location == "middle" ~ "qb_run_left",
nflfastr_play_type == "run" & nflfastr_rusher_position == "QB" & nflfastr_run_location == "right" ~ "qb_run_right",
nflfastr_play_type == "run" & nflfastr_rusher_position == "QB" & nflfastr_qb_scramble == 1 ~ "qb_scramble",
# WR or TE runs with direction
nflfastr_play_type == "run" & nflfastr_rusher_position %in% c("WR", "TE") & nflfastr_run_location == "left" ~ "wr_te_run_left",
nflfastr_play_type == "run" & nflfastr_rusher_position %in% c("WR", "TE") & nflfastr_run_location == "middle" ~ "wr_te_run_middle",
nflfastr_play_type == "run" & nflfastr_rusher_position %in% c("WR", "TE") & nflfastr_run_location == "right" ~ "wr_te_run_right",
# Short passes
nflfastr_play_type == "pass" & nflfastr_air_yards <= 10 & nflfastr_pass_location == "left" ~ "qb_pass_left_short",
nflfastr_play_type == "pass" & nflfastr_air_yards <= 10 & nflfastr_pass_location == "middle" ~ "qb_pass_middle_short",
nflfastr_play_type == "pass" & nflfastr_air_yards <= 10 & nflfastr_pass_location == "right" ~ "qb_pass_right_short",
# Intermediate passes
nflfastr_play_type == "pass" & nflfastr_air_yards > 10 & nflfastr_air_yards <= 20 & nflfastr_pass_location == "left" ~ "qb_pass_left_intermediate",
nflfastr_play_type == "pass" & nflfastr_air_yards > 10 & nflfastr_air_yards <= 20 & nflfastr_pass_location == "middle" ~ "qb_pass_middle_intermediate",
nflfastr_play_type == "pass" & nflfastr_air_yards > 10 & nflfastr_air_yards <= 20 & nflfastr_pass_location == "right" ~ "qb_pass_right_intermediate",
# Deep passes
nflfastr_play_type == "pass" & nflfastr_air_yards > 20 & nflfastr_pass_location == "left" ~ "qb_pass_left_deep",
nflfastr_play_type == "pass" & nflfastr_air_yards > 20 & nflfastr_pass_location == "middle" ~ "qb_pass_middle_deep",
nflfastr_play_type == "pass" & nflfastr_air_yards > 20 & nflfastr_pass_location == "right" ~ "qb_pass_right_deep",
# Other plays
TRUE ~ "other"
)
)
# Create a new working dataframe excluding "other" plays
plays_2022_working <- plays_2022_master %>%
filter(play_category != "other")
Our analysis utilizes data from multiple sources, including the NFL Big Data Bowl 2025 dataset and the nflfastR play-by-play data for the 2022 NFL season. The key files from the NFL Big Data Bowl 2025 include:
games.csv: Contains game-level
information such as participating teams, scores, and venues.player_play.csv: Maps individual
players to specific plays.players.csv: Provides player-level
information, including player IDs and positions.plays.csv: Contains detailed
play-by-play data for each game.Additionally, we incorporated nflfastR 2022 play-by-play data, which includes a comprehensive set of variables for each play in the 2022 NFL season.
Several variables from the datasets were crucial to our analysis:
ydstogo: The number of yards required
to achieve a first down.play_type: Indicates whether the play
was a pass, run, kick, or other type.air_yards: For passing plays, the
distance the ball traveled in the air beyond the line of scrimmage.run_location: For rushing plays,
specifies whether the run went left, middle, or right.epa: Expected Points Added, a measure
of the value a given play adds to a team’s likelihood of scoring.play_categoryWe developed the play_category variable
to classify plays into distinct and interpretable categories based on
their type and context. This variable combines
play_type,
air_yards, and
run_location to classify plays into
categories such as:
qb_pass_left_deep, qb_pass_middle_shortrb_run_left,
qb_run_middleqb_sneak,
qb_scrambleThis classification allowed us to perform granular analyses on play-calling tendencies and efficiency.
EntropyWe introduced entropy as a measure of unpredictability in play-calling. Derived from information theory, entropy quantifies the variability of play categories within a given context (e.g., team, down, distance).
Entropy was calculated using the formula:
\[ H = -\sum (p_i \cdot \log_2(p_i)) \]
Where \(p_i\) represents the probability of each play category. This statistic provides insights into whether teams with higher play-calling variability achieve better offensive outcomes (e.g., higher EPA per play).
We define a function to calculate the entropy of play outcome probabilities.
# Define a function to calculate entropy
calculate_entropy <- function(probabilities) {
-sum(probabilities * log2(probabilities), na.rm = TRUE)
}
Below is some example play entropy calculations for the Washington Commanders.
# Filter plays for the Washington Commanders
commanders_plays <- plays_2022_working %>%
filter(nflfastr_posteam == "WAS")
# Calculate play category probabilities
commanders_play_probs <- commanders_plays %>%
count(play_category) %>%
mutate(probability = n / sum(n)) # Calculate probabilities for each play category
# View play probabilities
print(commanders_play_probs)
## play_category n probability
## <char> <int> <num>
## 1: qb_pass_left_deep 14 0.026217228
## 2: qb_pass_left_intermediate 17 0.031835206
## 3: qb_pass_left_short 65 0.121722846
## 4: qb_pass_middle_deep 6 0.011235955
## 5: qb_pass_middle_intermediate 15 0.028089888
## 6: qb_pass_middle_short 71 0.132958801
## 7: qb_pass_right_deep 16 0.029962547
## 8: qb_pass_right_intermediate 19 0.035580524
## 9: qb_pass_right_short 89 0.166666667
## 10: qb_run_left 13 0.024344569
## 11: qb_run_right 9 0.016853933
## 12: qb_sneak 2 0.003745318
## 13: rb_run_left 88 0.164794007
## 14: rb_run_middle 22 0.041198502
## 15: rb_run_right 65 0.121722846
## 16: wr_te_run_left 10 0.018726592
## 17: wr_te_run_middle 1 0.001872659
## 18: wr_te_run_right 12 0.022471910
# Calculate entropy for the Washington Commanders
commanders_entropy <- calculate_entropy(commanders_play_probs$probability)
# Display the result
cat("Play-Calling Entropy for the Washington Commanders:", commanders_entropy, "\n")
## Play-Calling Entropy for the Washington Commanders: 3.519675
First, we examine play calling entropy by team.
team_entropy <- plays_2022_working %>%
group_by(nflfastr_posteam, play_category) %>%
count() %>% # Count occurrences of each play category for each team
group_by(nflfastr_posteam) %>% # Group by team for entropy calculation
mutate(probability = n / sum(n)) %>% # Calculate probability for each play category
summarize(
entropy = calculate_entropy(probability), # Calculate entropy
total_plays = sum(n), # Total plays for the team
avg_epa = mean(plays_2022_working$nflfastr_epa[nflfastr_posteam == first(nflfastr_posteam)], na.rm = TRUE), # Calculate avg EPA
.groups = "drop"
)
# Add a jitter effect to the y-axis for logos
set.seed(42) # For reproducibility
team_entropy <- team_entropy %>%
mutate(jittered_y = runif(n(), min = -0.1, max = 0)) # Random offset for logos
# Create a histogram of team entropy with logos
ggplot(team_entropy, aes(x = entropy)) +
geom_histogram(
bins = 10, # Adjust the number of bins as needed
fill = "steelblue",
color = "black",
alpha = 0.7 # Transparency for the histogram
) +
nflplotR::geom_nfl_logos(
aes(team_abbr = nflfastr_posteam, x = entropy, y = jittered_y), # Use jittered y for logos
height = 0.07, # Adjust the height of the logos
alpha = 0.8 # Transparency for the logos
) +
labs(
title = "Team Entropy Distribution with Logos",
x = "Entropy",
y = "Frequency"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", hjust = 0.5),
panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank()
)
## Team Entropy vs. EPA
Now we explore the relationship between Entropy and Average Expected Points Added Per Play.
# Calculate entropy and average EPA grouped by team
team_entropy <- plays_2022_working %>%
group_by(nflfastr_posteam, play_category) %>%
summarize(
total_plays = n(), # Total plays for the play category
avg_epa_per_category = mean(nflfastr_epa, na.rm = TRUE), # Average EPA per play category
.groups = "drop"
) %>%
group_by(nflfastr_posteam) %>% # Group by team for entropy calculation
mutate(probability = total_plays / sum(total_plays)) %>% # Probability of each play category
summarize(
entropy = calculate_entropy(probability), # Calculate entropy for the team
avg_epa = mean(avg_epa_per_category, na.rm = TRUE), # Average EPA across all plays
total_plays = sum(total_plays), # Total plays for the team
.groups = "drop"
)
# Add NFL logos for Entropy vs. EPA
ggplot(team_entropy, aes(x = entropy, y = avg_epa)) +
ggplot2::geom_abline(slope = 0, intercept = seq(-0.1, 0.5, by = 0.1), alpha = 0.2, linetype = "dotted") + # Optional guide lines
nflplotR::geom_mean_lines(aes(x0 = entropy, y0 = avg_epa)) + # Adds mean lines for reference
nflplotR::geom_nfl_logos(aes(team_abbr = nflfastr_posteam), width = 0.065, alpha = 0.8) + # Team logos as points
ggplot2::labs(
title = "Team Entropy vs. Offensive Efficiency (EPA)",
x = "Play-Calling Entropy",
y = "Average EPA per Play",
caption = "Data: nflfastR | Visualization: nflplotR"
) +
ggplot2::theme_minimal() +
ggplot2::theme(
plot.title = ggplot2::element_text(face = "bold", hjust = 0.5),
plot.title.position = "plot",
plot.background = ggplot2::element_rect(fill = "#F0F0F0"),
panel.grid.minor = ggplot2::element_blank()
)
At first glance, there does not appear to be an obvious positive linear relationship between higher entropy and EP. However, it is notable that some of the leagues best offenses like KC, MIA, BAL, and BUF have employ less predictable play calling, while several of the worst teams DET, CAR, and HOU demonstrate lower play calling entropy.
Now we investigate entropy with statistical analysis.
First, we fit a simple linear regression model for Entropy and EPA
# Fit and summarize a linear regression model
entropy_vs_epa_model <- lm(avg_epa ~ entropy, data = team_entropy)
summary(entropy_vs_epa_model)
##
## Call:
## lm(formula = avg_epa ~ entropy, data = team_entropy)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.31842 -0.11719 -0.02001 0.11550 0.34820
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) -0.5603 0.8471 -0.661 0.513
## entropy 0.2183 0.2478 0.881 0.385
##
## Residual standard error: 0.1662 on 30 degrees of freedom
## Multiple R-squared: 0.02522, Adjusted R-squared: -0.007276
## F-statistic: 0.7761 on 1 and 30 DF, p-value: 0.3854
Then we plot the model.
# Visualize the relationship between entropy and average EPA
ggplot(team_entropy, aes(x = entropy, y = avg_epa)) +
geom_point(color = "blue", size = 3, alpha = 0.7) + # Scatter plot
geom_smooth(method = "lm", color = "red", se = TRUE) + # Regression line with confidence interval
labs(
title = "Relationship Between Team Entropy and Offensive Efficiency (EPA)",
x = "Team Entropy",
y = "Average EPA per Play"
) +
theme_minimal()
### Insights from Regression Analysis
Weak Linear Relationship
The low \(R^2\) value (0.065) and the
lack of statistical significance (\(p >
0.05\)) suggest that entropy does not strongly predict EPA in
this model. The relationship may require additional variables or a more
complex model to explain the variance.
Potential Non-Linearity
The Residuals vs. Fitted plot indicates some curvature, suggesting that
the relationship between entropy and EPA may not be strictly linear. A
non-linear transformation or interaction terms could improve the model
fit.
Slight Heteroscedasticity
The Scale-Location plot shows a slight increase in the spread of
residuals for higher fitted values, indicating potential
heteroscedasticity (unequal variance of residuals), which may affect the
reliability of coefficient estimates.
Influential Observations
The Residuals vs. Leverage plot identifies a few influential data points
(e.g., #8 and #27) that could disproportionately impact the regression
results. These observations should be examined to determine if they
represent valid outliers or data quality issues.
The relationship between play-calling entropy and play success is non-linear because play-calling is situationally dependent. Let’s explore the Philadelphia Eagles tendencies on 3rd down to better understand this dynamic via the shiny app
You can access the app using the following link: Play Probabilities Heatmap.
On 3rd and short, PHI executed a wide array of plays, including their patented QB sneak, aka the “brotherly shove.”
On 3rd and medium PHI still had most of their call sheet at their disposal.
On 3rd and long, PHI attempted fewer play types to gain a first down.
Balance is Key: Teams like the Chiefs in the top-right quadrant showcase how balancing play unpredictability (high entropy) with strong execution leads to offensive success.
Efficiency Can Trump Creativity: Teams like the 49ers (top-left quadrant) demonstrate that even with more predictable play-calling, high efficiency can lead to offensive success when plays are executed effectively.
Unpredictability Alone Isn’t Enough: Teams like the Jets and Commanders (bottom-right quadrant) highlight that being unpredictable (high entropy) without strong execution leads to underwhelming results.
Struggling Teams Need Change: Teams in the bottom-left quadrant, such as the Raiders, are both predictable and inefficient. These teams may need to overhaul their play-calling strategy and execution to improve.
Top Performers as Models: Teams in the top-right quadrant (e.g., Chiefs and others) serve as benchmarks for how creativity in play-calling and high offensive efficiency can coexist and lead to elite performance.