5 min read

Understanding tempo, play selection, and situational decision-making

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:

  1. Defensive adjustment - If teams always passed, defenses would always play pass coverage
  2. Situational needs - Short-yardage, clock management, red zone
  3. Player limitations - Not all QBs can handle 50+ attempts
  4. Risk management - Passes have higher variance (sacks, INTs)
  5. 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

  1. Plays per game shows raw tempo but is affected by game script
  2. Neutral-situation pace isolates true tempo preference
  3. Seconds per play measures snap-to-snap speed

Pass Rate Analysis

  1. Neutral pass rate is the key metric for offensive philosophy
  2. Passing is more efficient than rushing league-wide
  3. Game script dramatically affects pass rate (trailing = more passing)
  4. Correlation with efficiency suggests teams should pass more

Fourth Down Decisions

  1. Expected value models identify optimal decisions
  2. Most teams are too conservative on 4th down
  3. Field position and distance determine optimal choice
  4. Time and score should modify decision thresholds

Predictability

  1. Situational tendencies can be exploited by defenses
  2. Balanced entropy suggests less predictable play-calling
  3. Formation tells can reveal play type intentions

Practice Exercises

  1. Calculate neutral pass rates for all teams and compare to overall pass rates
  2. Build a fourth down decision model and compare team decisions to optimal
  3. Analyze how pass rate changes with win probability throughout games
  4. Measure predictability by formation and identify tendency teams
  5. 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.