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.