Understanding tempo, play selection, and situational decision-making
In This Chapter
- Introduction
- Measuring Pace and Tempo
- Pass Rate Analysis
- The Pass Rate-Efficiency Relationship
- Situational Play Calling
- Fourth Down Decision Analysis
- Tendencies and Predictability
- Pace and Play Calling Efficiency
- Building a Complete Pace Analyzer
- Key Takeaways
- Practice Exercises
- Summary
- Preview: Chapter 14
Chapter 13: Pace and Play Calling
Understanding tempo, play selection, and situational decision-making
Introduction
Beyond raw efficiency, how teams choose to attack matters significantly. Some offenses methodically march down the field, while others sprint to the line and fire off plays rapidly. Some teams lean heavily on the pass, while others stubbornly commit to the run. Understanding pace and play-calling tendencies reveals both tactical philosophy and potential inefficiencies.
This chapter explores: - Tempo and pace metrics - measuring how fast teams operate - Play selection patterns - pass/run ratios and tendencies - Situational adjustments - how decisions change with game state - Optimal decision analysis - identifying suboptimal calls - Predictability and tendency exploitation
The ultimate question: Are teams making the decisions that maximize their chances of winning?
Measuring Pace and Tempo
Plays Per Game
The simplest pace metric counts offensive plays per game:
import pandas as pd
import numpy as np
import nfl_data_py as nfl
# Load data
pbp = nfl.import_pbp_data([2023])
# Filter to standard plays
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
].copy()
# Calculate plays per game
def calculate_pace_metrics(plays: pd.DataFrame) -> pd.DataFrame:
"""Calculate pace and tempo metrics for all teams."""
# Plays per team per game
plays_per_game = plays.groupby(['game_id', 'posteam']).size().reset_index()
plays_per_game.columns = ['game_id', 'team', 'plays']
# Average plays per game
avg_plays = plays_per_game.groupby('team')['plays'].mean()
return avg_plays.reset_index()
pace_metrics = calculate_pace_metrics(plays)
print(pace_metrics.sort_values('plays', ascending=False).head(10))
League Context: - High-tempo teams: 68-72 plays per game - League average: 62-65 plays per game - Low-tempo teams: 56-60 plays per game
Seconds Per Play
A more precise tempo measure examines time between plays:
def calculate_seconds_per_play(pbp: pd.DataFrame, team: str) -> float:
"""
Calculate average seconds between plays for a team.
Uses game clock changes between consecutive plays.
"""
team_drives = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run']))
].sort_values(['game_id', 'drive', 'play_id'])
# Calculate time between plays within same drive
team_drives['next_game_seconds'] = team_drives.groupby(
['game_id', 'drive']
)['game_seconds_remaining'].shift(-1)
team_drives['play_duration'] = (
team_drives['game_seconds_remaining'] -
team_drives['next_game_seconds']
)
# Filter valid durations (positive, reasonable)
valid_durations = team_drives[
(team_drives['play_duration'] > 0) &
(team_drives['play_duration'] < 45) # Exclude 2-minute drills, penalties
]['play_duration']
return valid_durations.mean()
# Calculate for all teams
teams = plays['posteam'].unique()
tempo_data = []
for team in teams:
seconds = calculate_seconds_per_play(pbp, team)
tempo_data.append({'team': team, 'seconds_per_play': seconds})
tempo_df = pd.DataFrame(tempo_data)
print(tempo_df.sort_values('seconds_per_play').head(10))
Interpretation: - Fast tempo: < 25 seconds between plays - Average tempo: 26-29 seconds - Slow tempo: > 30 seconds
Situation-Adjusted Pace
Raw pace doesn't account for game situations. A team trailing late will run faster regardless of preferred tempo:
def calculate_neutral_pace(pbp: pd.DataFrame, team: str) -> float:
"""
Calculate pace in neutral game situations only.
Neutral = 1st/2nd down, score within 10, middle quarters.
"""
neutral_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['down'].isin([1, 2])) &
(pbp['score_differential'].abs() <= 10) &
(pbp['qtr'].isin([2, 3])) # Middle quarters
]
# Calculate plays per game in neutral situations
neutral_per_game = neutral_plays.groupby('game_id').size().mean()
return neutral_per_game
# Neutral pace rankings
neutral_pace = []
for team in teams:
pace = calculate_neutral_pace(pbp, team)
neutral_pace.append({'team': team, 'neutral_plays_per_game': pace})
neutral_df = pd.DataFrame(neutral_pace)
print("Neutral Situation Pace (1st/2nd down, close games):")
print(neutral_df.sort_values('neutral_plays_per_game', ascending=False).head(10))
Pass Rate Analysis
Overall Pass Rate
The percentage of plays that are passes (excluding spikes, kneels, and penalties):
def calculate_pass_rate(plays: pd.DataFrame, team: str) -> dict:
"""Calculate various pass rate metrics for a team."""
team_plays = plays[plays['posteam'] == team]
overall = (team_plays['play_type'] == 'pass').mean()
return {'team': team, 'overall_pass_rate': overall}
# League-wide pass rates
pass_rates = []
for team in teams:
rates = calculate_pass_rate(plays, team)
pass_rates.append(rates)
pass_df = pd.DataFrame(pass_rates)
print(f"League Average Pass Rate: {pass_df['overall_pass_rate'].mean():.1%}")
print(f"Range: {pass_df['overall_pass_rate'].min():.1%} to {pass_df['overall_pass_rate'].max():.1%}")
2023 Context: - League average: ~58% pass rate - High-pass teams: 62-68% - Run-heavy teams: 50-55%
Neutral Pass Rate (Critical Metric)
Pass rate in neutral situations isolates true offensive philosophy from game-script effects:
def calculate_neutral_pass_rate(pbp: pd.DataFrame, team: str) -> float:
"""
Pass rate in neutral game situations.
Definition: 1st/2nd down, score within 7, WP 30-70%
"""
neutral = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['down'].isin([1, 2])) &
(pbp['score_differential'].abs() <= 7) &
(pbp['wp'] >= 0.30) &
(pbp['wp'] <= 0.70)
]
if len(neutral) == 0:
return None
return (neutral['play_type'] == 'pass').mean()
# Calculate neutral pass rates
neutral_pass = []
for team in teams:
rate = calculate_neutral_pass_rate(pbp, team)
if rate is not None:
neutral_pass.append({'team': team, 'neutral_pass_rate': rate})
neutral_pass_df = pd.DataFrame(neutral_pass)
print("\nNeutral Pass Rates:")
print(neutral_pass_df.sort_values('neutral_pass_rate', ascending=False).head(10))
Why Neutral Pass Rate Matters: 1. Isolates preference from situation - removes trailing/leading bias 2. Predicts future efficiency - correlates with offensive quality 3. Identifies philosophy - true offensive identity 4. Stable across games - less affected by opponent or score
Early Down Pass Rate
Many analysts focus specifically on first and second down:
def calculate_early_down_rates(pbp: pd.DataFrame, team: str) -> dict:
"""Calculate pass rates by down."""
team_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run']))
]
rates = {}
for down in [1, 2]:
down_plays = team_plays[team_plays['down'] == down]
rates[f'down_{down}_pass_rate'] = (down_plays['play_type'] == 'pass').mean()
# Combined early down
early = team_plays[team_plays['down'].isin([1, 2])]
rates['early_down_pass_rate'] = (early['play_type'] == 'pass').mean()
return rates
# Example
kc_rates = calculate_early_down_rates(pbp, 'KC')
print("KC Early Down Pass Rates:")
for key, value in kc_rates.items():
print(f" {key}: {value:.1%}")
The Pass Rate-Efficiency Relationship
League-Wide Pattern
Passing is more efficient than rushing on average. This creates a clear relationship:
def analyze_pass_rate_efficiency(pbp: pd.DataFrame) -> pd.DataFrame:
"""Analyze relationship between pass rate and efficiency."""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
team_data = []
for team in plays['posteam'].unique():
team_plays = plays[plays['posteam'] == team]
pass_rate = (team_plays['play_type'] == 'pass').mean()
off_epa = team_plays['epa'].mean()
pass_epa = team_plays[team_plays['play_type'] == 'pass']['epa'].mean()
rush_epa = team_plays[team_plays['play_type'] == 'run']['epa'].mean()
team_data.append({
'team': team,
'pass_rate': pass_rate,
'off_epa': off_epa,
'pass_epa': pass_epa,
'rush_epa': rush_epa
})
return pd.DataFrame(team_data)
efficiency_data = analyze_pass_rate_efficiency(pbp)
# Correlation analysis
from scipy import stats
corr, p_value = stats.pearsonr(
efficiency_data['pass_rate'],
efficiency_data['off_epa']
)
print(f"Correlation (Pass Rate vs EPA): {corr:.3f} (p = {p_value:.4f})")
Typical Finding: r ≈ 0.40-0.55
Teams that pass more tend to be more efficient. This doesn't prove causation—good teams may pass more because they can—but the pattern is consistent.
Why Don't Teams Pass More?
Given the efficiency advantage of passing, why maintain any rushing?
Game Theory Considerations:
- Defensive adjustment - If teams always passed, defenses would always play pass coverage
- Situational needs - Short-yardage, clock management, red zone
- Player limitations - Not all QBs can handle 50+ attempts
- Risk management - Passes have higher variance (sacks, INTs)
- Injury concerns - Protecting the quarterback
def calculate_play_variance(pbp: pd.DataFrame, team: str) -> dict:
"""Compare variance between pass and rush."""
team_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
passes = team_plays[team_plays['play_type'] == 'pass']['epa']
rushes = team_plays[team_plays['play_type'] == 'run']['epa']
return {
'pass_mean': passes.mean(),
'pass_std': passes.std(),
'rush_mean': rushes.mean(),
'rush_std': rushes.std(),
'pass_negative_rate': (passes < 0).mean(),
'rush_negative_rate': (rushes < 0).mean()
}
variance_data = calculate_play_variance(pbp, 'KC')
print("Play Type Variance Analysis:")
for key, value in variance_data.items():
print(f" {key}: {value:.3f}")
Typical Pattern: - Pass EPA: mean +0.05, std 1.5 - Rush EPA: mean -0.03, std 0.9
Passes have higher mean and higher variance.
Situational Play Calling
Game Script Effects
How does score affect play calling?
def analyze_game_script(pbp: pd.DataFrame, team: str) -> pd.DataFrame:
"""Analyze play calling by score differential."""
team_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run']))
]
# Define score buckets
def score_bucket(diff):
if diff <= -14:
return "Down 14+"
elif diff <= -7:
return "Down 7-13"
elif diff < 0:
return "Down 1-6"
elif diff == 0:
return "Tied"
elif diff <= 6:
return "Up 1-6"
elif diff <= 13:
return "Up 7-13"
else:
return "Up 14+"
team_plays['score_bucket'] = team_plays['score_differential'].apply(score_bucket)
# Calculate pass rate by bucket
result = team_plays.groupby('score_bucket').agg(
pass_rate=('play_type', lambda x: (x == 'pass').mean()),
plays=('play_type', 'count'),
epa=('epa', 'mean')
).reset_index()
# Order buckets
bucket_order = ["Down 14+", "Down 7-13", "Down 1-6", "Tied",
"Up 1-6", "Up 7-13", "Up 14+"]
result['order'] = result['score_bucket'].map(
{b: i for i, b in enumerate(bucket_order)}
)
return result.sort_values('order')
# Example
game_script = analyze_game_script(pbp, 'KC')
print("KC Play Calling by Score:")
print(game_script.to_string(index=False))
Expected Pattern: | Score State | Pass Rate | |-------------|-----------| | Down 14+ | 70-80% | | Down 7-13 | 60-70% | | Down 1-6 | 55-60% | | Tied | 50-55% | | Up 1-6 | 50-55% | | Up 7-13 | 45-50% | | Up 14+ | 35-45% |
Quarter-by-Quarter Analysis
Play calling often shifts as games progress:
def analyze_by_quarter(pbp: pd.DataFrame, team: str) -> pd.DataFrame:
"""Analyze play calling by quarter."""
team_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run']))
]
result = team_plays.groupby('qtr').agg(
pass_rate=('play_type', lambda x: (x == 'pass').mean()),
plays=('play_type', 'count'),
epa=('epa', 'mean')
).reset_index()
return result
quarter_analysis = analyze_by_quarter(pbp, 'KC')
print("KC Play Calling by Quarter:")
print(quarter_analysis.to_string(index=False))
Down and Distance Patterns
Perhaps the most important situational analysis:
def analyze_down_distance(pbp: pd.DataFrame, team: str) -> pd.DataFrame:
"""Analyze play calling by down and distance."""
team_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['down'].isin([1, 2, 3]))
].copy()
# Distance categories
def distance_category(row):
if row['down'] == 1:
return "1st & 10"
elif row['down'] == 2:
if row['ydstogo'] <= 3:
return "2nd & Short"
elif row['ydstogo'] <= 6:
return "2nd & Medium"
else:
return "2nd & Long"
else: # 3rd down
if row['ydstogo'] <= 3:
return "3rd & Short"
elif row['ydstogo'] <= 6:
return "3rd & Medium"
else:
return "3rd & Long"
team_plays['situation'] = team_plays.apply(distance_category, axis=1)
result = team_plays.groupby('situation').agg(
pass_rate=('play_type', lambda x: (x == 'pass').mean()),
plays=('play_type', 'count'),
epa=('epa', 'mean'),
success_rate=('epa', lambda x: (x > 0).mean())
).reset_index()
return result
down_distance = analyze_down_distance(pbp, 'KC')
print("KC Play Calling by Down/Distance:")
print(down_distance.to_string(index=False))
Fourth Down Decision Analysis
Fourth downs represent the clearest measurable decisions. Teams must choose between: 1. Punt - Give up possession, gain field position 2. Field Goal - Attempt 3 points 3. Go for it - Try to convert
Expected Value Framework
def fourth_down_expected_value(
field_position: int,
distance: int,
score_diff: int,
time_remaining: float
) -> dict:
"""
Calculate expected value of 4th down options.
Args:
field_position: Yards from opponent's end zone
distance: Yards to go for first down
score_diff: Current score differential (positive = leading)
time_remaining: Minutes remaining in game
Returns:
Dictionary with EV for each option
"""
# Conversion probability (simplified model)
# Based on historical data by distance
conversion_probs = {
1: 0.73, 2: 0.63, 3: 0.55, 4: 0.48,
5: 0.44, 6: 0.40, 7: 0.36, 8: 0.33,
9: 0.30, 10: 0.28
}
conversion_prob = conversion_probs.get(min(distance, 10), 0.25)
# Field goal probability (by distance)
fg_distance = field_position + 17 # Add end zone + snap
if fg_distance <= 30:
fg_prob = 0.93
elif fg_distance <= 40:
fg_prob = 0.85
elif fg_distance <= 50:
fg_prob = 0.70
else:
fg_prob = 0.50
# Expected points from field position (simplified)
def ep_from_position(pos):
"""EP value based on field position."""
# Approximation: linear from -2 (own goal line) to +6 (opp goal line)
return (100 - pos) / 100 * 7 - 1
# Expected value of going for it
ep_success = ep_from_position(field_position - distance) # Gain first down
ep_failure = -ep_from_position(100 - field_position) # Turnover on downs
ev_go = conversion_prob * ep_success + (1 - conversion_prob) * ep_failure
# Expected value of field goal
ev_fg = fg_prob * 3 + (1 - fg_prob) * (-ep_from_position(100 - field_position + 7))
# Expected value of punt (assume 45-yard net)
punt_distance = min(45, field_position - 20) # Can't punt into end zone effectively
new_opponent_position = max(20, field_position - punt_distance)
ev_punt = -ep_from_position(new_opponent_position)
return {
'go_for_it': round(ev_go, 2),
'field_goal': round(ev_fg, 2) if field_position <= 45 else None,
'punt': round(ev_punt, 2),
'recommendation': max(
[('go', ev_go), ('fg', ev_fg if field_position <= 45 else -999), ('punt', ev_punt)],
key=lambda x: x[1]
)[0]
}
# Example scenarios
scenarios = [
(50, 1, 0, 30), # Midfield, 4th & 1, tied, 30 min left
(35, 3, 0, 30), # Opponent 35, 4th & 3, tied
(40, 5, -7, 5), # Opponent 40, 4th & 5, down 7, 5 min left
(25, 2, 3, 2), # Opponent 25, 4th & 2, up 3, 2 min left
]
print("Fourth Down Decision Analysis:")
print("-" * 60)
for fp, dist, score, time in scenarios:
result = fourth_down_expected_value(fp, dist, score, time)
print(f"\n4th & {dist} at opponent's {fp}, Score: {score:+d}, Time: {time}min")
print(f" Go for it EV: {result['go_for_it']:.2f}")
if result['field_goal'] is not None:
print(f" Field Goal EV: {result['field_goal']:.2f}")
print(f" Punt EV: {result['punt']:.2f}")
print(f" Recommendation: {result['recommendation'].upper()}")
Analyzing Team 4th Down Decisions
def analyze_fourth_down_decisions(pbp: pd.DataFrame, team: str) -> dict:
"""Analyze a team's 4th down decision-making."""
fourth_downs = pbp[
(pbp['posteam'] == team) &
(pbp['down'] == 4) &
(pbp['yardline_100'].notna())
]
# Categorize decisions
fourth_downs['decision'] = fourth_downs['play_type'].apply(
lambda x: 'go' if x in ['pass', 'run'] else
'fg' if x == 'field_goal' else
'punt' if x == 'punt' else 'other'
)
# Overall breakdown
decision_counts = fourth_downs['decision'].value_counts()
# Go-for-it rate by distance
go_by_distance = fourth_downs[fourth_downs['ydstogo'] <= 5].groupby('ydstogo').apply(
lambda x: (x['decision'] == 'go').mean()
)
# Go-for-it rate by field position
fourth_downs['fp_zone'] = pd.cut(
fourth_downs['yardline_100'],
bins=[0, 30, 50, 70, 100],
labels=['Red Zone', 'Opp Territory', 'Midfield', 'Own Territory']
)
go_by_zone = fourth_downs.groupby('fp_zone').apply(
lambda x: (x['decision'] == 'go').mean()
)
return {
'total_4th_downs': len(fourth_downs),
'decision_breakdown': decision_counts.to_dict(),
'go_rate_by_distance': go_by_distance.to_dict(),
'go_rate_by_zone': go_by_zone.to_dict()
}
# Example
fourth_analysis = analyze_fourth_down_decisions(pbp, 'KC')
print("\nKC Fourth Down Analysis:")
print(f"Total 4th downs: {fourth_analysis['total_4th_downs']}")
print(f"Decisions: {fourth_analysis['decision_breakdown']}")
Tendencies and Predictability
Measuring Predictability
Predictable play-calling can be exploited by defenses:
def calculate_predictability(pbp: pd.DataFrame, team: str) -> dict:
"""
Measure how predictable a team's play calling is.
Uses entropy and conditional probabilities.
"""
team_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run']))
].copy()
# Overall pass rate
overall_pass_rate = (team_plays['play_type'] == 'pass').mean()
# Shannon entropy (higher = less predictable)
def entropy(p):
if p == 0 or p == 1:
return 0
return -p * np.log2(p) - (1-p) * np.log2(1-p)
overall_entropy = entropy(overall_pass_rate)
# Conditional predictability by situation
situations = []
# 1st & 10
first_ten = team_plays[(team_plays['down'] == 1) & (team_plays['ydstogo'] == 10)]
first_ten_pass = (first_ten['play_type'] == 'pass').mean()
situations.append(('1st & 10', first_ten_pass, entropy(first_ten_pass)))
# 2nd & long
second_long = team_plays[(team_plays['down'] == 2) & (team_plays['ydstogo'] >= 7)]
second_long_pass = (second_long['play_type'] == 'pass').mean()
situations.append(('2nd & Long', second_long_pass, entropy(second_long_pass)))
# 3rd & short
third_short = team_plays[(team_plays['down'] == 3) & (team_plays['ydstogo'] <= 3)]
third_short_pass = (third_short['play_type'] == 'pass').mean()
situations.append(('3rd & Short', third_short_pass, entropy(third_short_pass)))
# Average conditional entropy
avg_conditional_entropy = np.mean([s[2] for s in situations])
return {
'overall_pass_rate': overall_pass_rate,
'overall_entropy': overall_entropy,
'situational_breakdown': situations,
'avg_conditional_entropy': avg_conditional_entropy,
'predictability_score': 1 - avg_conditional_entropy # 0 = unpredictable, 1 = predictable
}
pred = calculate_predictability(pbp, 'KC')
print("Predictability Analysis:")
print(f"Overall entropy: {pred['overall_entropy']:.3f}")
print(f"Avg conditional entropy: {pred['avg_conditional_entropy']:.3f}")
print(f"Predictability score: {pred['predictability_score']:.3f}")
Formation and Personnel Tendencies
Advanced tendency analysis examines formations:
def analyze_formation_tendencies(pbp: pd.DataFrame, team: str) -> pd.DataFrame:
"""Analyze play calling by formation/personnel."""
team_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['offense_formation'].notna())
]
result = team_plays.groupby('offense_formation').agg(
plays=('play_type', 'count'),
pass_rate=('play_type', lambda x: (x == 'pass').mean()),
epa=('epa', 'mean')
).reset_index()
result = result[result['plays'] >= 20] # Minimum sample
return result.sort_values('plays', ascending=False)
formation_data = analyze_formation_tendencies(pbp, 'KC')
print("KC Formation Tendencies:")
print(formation_data.to_string(index=False))
Pace and Play Calling Efficiency
Tempo Impact on Efficiency
Does pace affect play quality?
def analyze_tempo_efficiency(pbp: pd.DataFrame) -> pd.DataFrame:
"""Analyze relationship between pace and efficiency."""
plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
team_data = []
for team in plays['posteam'].unique():
team_plays = plays[plays['posteam'] == team]
# Plays per game
games = team_plays['game_id'].nunique()
plays_per_game = len(team_plays) / games
# Efficiency
epa = team_plays['epa'].mean()
success_rate = (team_plays['epa'] > 0).mean()
team_data.append({
'team': team,
'plays_per_game': plays_per_game,
'epa': epa,
'success_rate': success_rate
})
return pd.DataFrame(team_data)
tempo_eff = analyze_tempo_efficiency(pbp)
# Correlation
corr_pace_epa = tempo_eff['plays_per_game'].corr(tempo_eff['epa'])
print(f"Correlation (Pace vs EPA): {corr_pace_epa:.3f}")
Typical Finding: Weak positive correlation (r ≈ 0.2-0.3)
Faster teams tend to be slightly more efficient, but causality is unclear.
Optimal Play Selection Model
class PlaySelectionOptimizer:
"""
Model for evaluating optimal play selection.
Compares actual pass rate to theoretically optimal rate
based on relative efficiency.
"""
def __init__(self, pbp: pd.DataFrame):
self.pbp = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
def calculate_optimal_rates(self, team: str) -> dict:
"""
Calculate optimal pass rate based on relative efficiency.
Uses game theory equilibrium concept.
"""
team_plays = self.pbp[self.pbp['posteam'] == team]
pass_plays = team_plays[team_plays['play_type'] == 'pass']
rush_plays = team_plays[team_plays['play_type'] == 'run']
pass_epa = pass_plays['epa'].mean()
rush_epa = rush_plays['epa'].mean()
actual_pass_rate = len(pass_plays) / len(team_plays)
# Simplified optimal: if pass >> rush, should pass more
# This is a heuristic; true optimal requires game theory model
epa_diff = pass_epa - rush_epa
# Suggest rate based on efficiency gap
# Larger gap -> higher recommended pass rate
suggested_pass_rate = 0.50 + (epa_diff * 2) # Rough heuristic
suggested_pass_rate = max(0.35, min(0.75, suggested_pass_rate))
return {
'actual_pass_rate': actual_pass_rate,
'pass_epa': pass_epa,
'rush_epa': rush_epa,
'epa_gap': epa_diff,
'suggested_pass_rate': suggested_pass_rate,
'pass_rate_gap': suggested_pass_rate - actual_pass_rate
}
def evaluate_all_teams(self) -> pd.DataFrame:
"""Evaluate optimal rates for all teams."""
results = []
for team in self.pbp['posteam'].unique():
data = self.calculate_optimal_rates(team)
data['team'] = team
results.append(data)
return pd.DataFrame(results)
optimizer = PlaySelectionOptimizer(pbp)
optimal_rates = optimizer.evaluate_all_teams()
print("\nPlay Selection Analysis:")
print(optimal_rates.sort_values('pass_rate_gap', ascending=False).head(10).to_string(index=False))
Building a Complete Pace Analyzer
from dataclasses import dataclass
from typing import Optional
@dataclass
class PaceReport:
"""Complete pace and play calling report."""
team: str
season: int
# Pace metrics
plays_per_game: float
seconds_per_play: float
neutral_plays_per_game: float
# Pass rates
overall_pass_rate: float
neutral_pass_rate: float
early_down_pass_rate: float
# Efficiency by play type
pass_epa: float
rush_epa: float
pass_success_rate: float
rush_success_rate: float
# Situational
trailing_pass_rate: float
leading_pass_rate: float
# Fourth down
fourth_down_go_rate: float
fourth_down_success_rate: float
# Predictability
predictability_score: float
class PaceAnalyzer:
"""Comprehensive pace and play calling analyzer."""
def __init__(self, pbp: pd.DataFrame, season: int = 2023):
self.pbp = pbp
self.season = season
self.plays = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
].copy()
def analyze_team(self, team: str) -> PaceReport:
"""Generate complete pace report for a team."""
team_plays = self.plays[self.plays['posteam'] == team]
# Pace metrics
games = team_plays['game_id'].nunique()
plays_per_game = len(team_plays) / games
# Neutral pace (simplified)
neutral = team_plays[
(team_plays['down'].isin([1, 2])) &
(team_plays['score_differential'].abs() <= 7)
]
neutral_ppg = len(neutral) / games
# Pass rates
overall_pass = (team_plays['play_type'] == 'pass').mean()
neutral_pass = (neutral['play_type'] == 'pass').mean() if len(neutral) > 0 else overall_pass
early_down = team_plays[team_plays['down'].isin([1, 2])]
early_down_pass = (early_down['play_type'] == 'pass').mean()
# Efficiency
passes = team_plays[team_plays['play_type'] == 'pass']
rushes = team_plays[team_plays['play_type'] == 'run']
pass_epa = passes['epa'].mean()
rush_epa = rushes['epa'].mean()
pass_success = (passes['epa'] > 0).mean()
rush_success = (rushes['epa'] > 0).mean()
# Situational
trailing = team_plays[team_plays['score_differential'] < 0]
leading = team_plays[team_plays['score_differential'] > 0]
trailing_pass = (trailing['play_type'] == 'pass').mean() if len(trailing) > 0 else 0.5
leading_pass = (leading['play_type'] == 'pass').mean() if len(leading) > 0 else 0.5
# Fourth down
fourth = self.pbp[
(self.pbp['posteam'] == team) &
(self.pbp['down'] == 4)
]
fourth_goes = fourth[fourth['play_type'].isin(['pass', 'run'])]
fourth_go_rate = len(fourth_goes) / len(fourth) if len(fourth) > 0 else 0
fourth_success = (fourth_goes['epa'] > 0).mean() if len(fourth_goes) > 0 else 0
# Predictability (simplified)
pred = calculate_predictability(self.pbp, team)
return PaceReport(
team=team,
season=self.season,
plays_per_game=round(plays_per_game, 1),
seconds_per_play=0, # Would need more calculation
neutral_plays_per_game=round(neutral_ppg, 1),
overall_pass_rate=round(overall_pass, 3),
neutral_pass_rate=round(neutral_pass, 3),
early_down_pass_rate=round(early_down_pass, 3),
pass_epa=round(pass_epa, 3),
rush_epa=round(rush_epa, 3),
pass_success_rate=round(pass_success, 3),
rush_success_rate=round(rush_success, 3),
trailing_pass_rate=round(trailing_pass, 3),
leading_pass_rate=round(leading_pass, 3),
fourth_down_go_rate=round(fourth_go_rate, 3),
fourth_down_success_rate=round(fourth_success, 3),
predictability_score=round(pred['predictability_score'], 3)
)
Key Takeaways
Pace Metrics
- Plays per game shows raw tempo but is affected by game script
- Neutral-situation pace isolates true tempo preference
- Seconds per play measures snap-to-snap speed
Pass Rate Analysis
- Neutral pass rate is the key metric for offensive philosophy
- Passing is more efficient than rushing league-wide
- Game script dramatically affects pass rate (trailing = more passing)
- Correlation with efficiency suggests teams should pass more
Fourth Down Decisions
- Expected value models identify optimal decisions
- Most teams are too conservative on 4th down
- Field position and distance determine optimal choice
- Time and score should modify decision thresholds
Predictability
- Situational tendencies can be exploited by defenses
- Balanced entropy suggests less predictable play-calling
- Formation tells can reveal play type intentions
Practice Exercises
- Calculate neutral pass rates for all teams and compare to overall pass rates
- Build a fourth down decision model and compare team decisions to optimal
- Analyze how pass rate changes with win probability throughout games
- Measure predictability by formation and identify tendency teams
- Correlate pace metrics with offensive efficiency
Summary
Pace and play-calling analysis reveals how teams deploy their resources. Key findings include:
- Passing is more efficient than rushing, yet teams maintain significant rushing
- Game script drives much of observed play-calling variation
- Fourth down decisions remain conservative relative to expected value
- Tempo has modest impact on efficiency
- Predictability can be measured and potentially exploited
Understanding these patterns helps evaluate coaching decisions and identify market inefficiencies in how teams are perceived.
Preview: Chapter 14
Next, we'll explore Situational Football in depth—analyzing red zone efficiency, third down conversions, two-minute drills, and other critical game situations.