Introduction

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")

Data Sources

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:

Additionally, we incorporated nflfastR 2022 play-by-play data, which includes a comprehensive set of variables for each play in the 2022 NFL season.


Key Variables

Several variables from the datasets were crucial to our analysis:


Custom Variable: play_category

We 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:

This classification allowed us to perform granular analyses on play-calling tendencies and efficiency.


New Statistic: Entropy

We 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).

How It Works:

  • Higher entropy: Indicates a more diverse or unpredictable set of play calls.
  • Lower entropy: Reflects a more predictable play-calling strategy.

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).

Calculating Entropy

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)
}

Sample Entropy Data - Washington Commanders

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

Team Entropy

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Play Calling Tendacies

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.”

3rd and short
3rd and short

On 3rd and medium PHI still had most of their call sheet at their disposal.

3rd and short
3rd and short

On 3rd and long, PHI attempted fewer play types to gain a first down. 3rd and short

Key Insights from the Analysis