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.