Learning ObjectivesBy the end of this chapter, you will be able to:
- Understand time value and game theory principles in clock management
- Optimize timeout usage across different game situations
- Analyze two-minute drill strategy and execution
- Study end-of-half and end-of-game scenarios
- Evaluate coaching clock management decisions using data
Introduction
Time is the one resource in football that cannot be recovered once spent. Unlike yards, which can be gained back, or points, which can be erased by defensive stops, time flows in only one direction. This fundamental constraint makes clock management one of the most critical—and often misunderstood—aspects of football strategy.
Modern analytics has revealed that many traditional approaches to clock management are suboptimal. Coaches frequently make decisions that reduce their team's win probability through poor timeout usage, overly conservative play-calling, or failure to understand the strategic value of time in different game situations.
In this chapter, we'll explore how to quantify the value of time, optimize timeout usage, analyze two-minute drill efficiency, and evaluate end-game scenarios using rigorous data analysis.
What is Clock Management?
Clock management encompasses all strategic decisions related to the game clock, including when to let time run or stop it, timeout usage, play selection based on clock considerations, and pace of play adjustments. Effective clock management can add several percentage points to win probability in critical situations.Time as a Resource and Game Theory
The Value of Time
Time has different values depending on game context. With a large lead late in the game, running time is extremely valuable for the leading team—each second that elapses without the trailing team scoring increases the leader's win probability. Conversely, time is the trailing team's most precious resource.
We can formalize this relationship using win probability:
$$ \text{Time Value} = \frac{\partial \text{WP}}{\partial t} $$
Where the time value represents the change in win probability per unit of time elapsed. This derivative varies by:
- Score differential
- Time remaining
- Timeouts available
- Field position
- Down and distance
Game Theory Framework
Clock management involves strategic interactions between offense and defense:
When Leading:
- Offense wants to maximize time elapsed while maintaining possession
- Defense wants to stop the clock and force quick possessions
- Optimal strategy: Run plays that minimize clock stoppage risk
When Trailing:
- Offense wants to maximize possession value while conserving time
- Defense wants to minimize time value of opponent's possessions
- Optimal strategy: Balance between scoring efficiency and time conservation
The Clock Management Paradox
Counter-intuitively, teams trailing late in games often benefit from allowing the clock to run on first down. This preserves the ability to stop the clock on later downs while still maximizing the number of plays that can be run.Analyzing Time Value by Game State
Let's examine how win probability changes with time remaining across different score differentials:
#| label: load-packages-r
#| message: false
#| warning: false
library(tidyverse)
library(nflfastR)
library(nflplotR)
library(gt)
library(mgcv)
# Load multiple seasons for robust estimates
pbp <- load_pbp(2020:2023)
#| label: time-value-analysis-r
#| message: false
#| warning: false
# Calculate win probability by time and score
time_value <- pbp %>%
filter(
!is.na(wp),
!is.na(score_differential),
qtr %in% c(4), # Focus on 4th quarter
game_seconds_remaining > 0
) %>%
mutate(
score_diff_bucket = case_when(
score_differential >= 7 ~ "Leading by 7+",
score_differential >= 3 ~ "Leading by 3-6",
score_differential >= 1 ~ "Leading by 1-2",
score_differential == 0 ~ "Tied",
score_differential >= -2 ~ "Trailing by 1-2",
score_differential >= -6 ~ "Trailing by 3-6",
TRUE ~ "Trailing by 7+"
),
time_remaining_min = game_seconds_remaining / 60
) %>%
filter(score_diff_bucket %in% c(
"Leading by 7+", "Leading by 3-6", "Tied",
"Trailing by 3-6", "Trailing by 7+"
))
# Calculate average WP by time buckets
wp_by_time <- time_value %>%
mutate(time_bucket = floor(time_remaining_min)) %>%
group_by(score_diff_bucket, time_bucket) %>%
summarise(
avg_wp = mean(wp, na.rm = TRUE),
plays = n(),
.groups = "drop"
) %>%
filter(plays >= 50) # Ensure sufficient sample size
# Display summary statistics
wp_by_time %>%
filter(time_bucket %in% c(2, 5, 10)) %>%
pivot_wider(
names_from = time_bucket,
values_from = avg_wp,
names_prefix = "min_"
) %>%
gt() %>%
cols_label(
score_diff_bucket = "Game State",
min_2 = "2 Min",
min_5 = "5 Min",
min_10 = "10 Min"
) %>%
fmt_percent(
columns = starts_with("min_"),
decimals = 1
) %>%
tab_header(
title = "Win Probability by Time Remaining",
subtitle = "4th Quarter, 2020-2023 Seasons"
)
#| label: load-packages-py
#| message: false
#| warning: false
import pandas as pd
import numpy as np
import nfl_data_py as nfl
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
# Load multiple seasons for robust estimates
pbp = nfl.import_pbp_data(range(2020, 2024))
#| label: time-value-analysis-py
#| message: false
#| warning: false
# Calculate win probability by time and score
def categorize_score_diff(diff):
if diff >= 7:
return "Leading by 7+"
elif diff >= 3:
return "Leading by 3-6"
elif diff >= 1:
return "Leading by 1-2"
elif diff == 0:
return "Tied"
elif diff >= -2:
return "Trailing by 1-2"
elif diff >= -6:
return "Trailing by 3-6"
else:
return "Trailing by 7+"
time_value = (pbp
.query("wp.notna() & score_differential.notna() & qtr == 4 & game_seconds_remaining > 0")
.copy()
)
time_value['score_diff_bucket'] = time_value['score_differential'].apply(categorize_score_diff)
time_value['time_remaining_min'] = time_value['game_seconds_remaining'] / 60
# Filter to key scenarios
key_scenarios = [
"Leading by 7+", "Leading by 3-6", "Tied",
"Trailing by 3-6", "Trailing by 7+"
]
time_value = time_value[time_value['score_diff_bucket'].isin(key_scenarios)]
# Calculate average WP by time buckets
time_value['time_bucket'] = np.floor(time_value['time_remaining_min']).astype(int)
wp_by_time = (time_value
.groupby(['score_diff_bucket', 'time_bucket'])
.agg(
avg_wp=('wp', 'mean'),
plays=('wp', 'count')
)
.reset_index()
.query("plays >= 50") # Ensure sufficient sample size
)
# Display summary statistics
wp_summary = (wp_by_time
.query("time_bucket in [2, 5, 10]")
.pivot(index='score_diff_bucket', columns='time_bucket', values='avg_wp')
)
print("\nWin Probability by Time Remaining (4th Quarter, 2020-2023)")
print("=" * 70)
print(wp_summary.to_string())
Visualizing Time Value
#| label: fig-time-value-r
#| fig-cap: "Win probability by time remaining in 4th quarter"
#| fig-width: 12
#| fig-height: 8
wp_by_time %>%
filter(score_diff_bucket != "Tied") %>%
ggplot(aes(x = time_bucket, y = avg_wp, color = score_diff_bucket)) +
geom_line(size = 1.2) +
geom_point(size = 2) +
scale_color_manual(
values = c(
"Leading by 7+" = "#006400",
"Leading by 3-6" = "#90EE90",
"Trailing by 3-6" = "#FFB6C6",
"Trailing by 7+" = "#8B0000"
)
) +
scale_y_continuous(
labels = scales::percent_format(),
limits = c(0, 1)
) +
scale_x_continuous(breaks = seq(0, 15, 2)) +
labs(
title = "How Time Affects Win Probability",
subtitle = "4th Quarter Win Probability by Score Differential and Time Remaining",
x = "Minutes Remaining in 4th Quarter",
y = "Win Probability",
color = "Game State",
caption = "Data: nflfastR | 2020-2023 Seasons"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 16),
plot.subtitle = element_text(size = 12),
legend.position = "right",
legend.title = element_text(face = "bold"),
panel.grid.minor = element_blank()
)
#| label: fig-time-value-py
#| fig-cap: "Win probability by time remaining in 4th quarter - Python"
#| fig-width: 12
#| fig-height: 8
colors = {
"Leading by 7+": "#006400",
"Leading by 3-6": "#90EE90",
"Trailing by 3-6": "#FFB6C6",
"Trailing by 7+": "#8B0000"
}
plt.figure(figsize=(12, 8))
for scenario in ["Leading by 7+", "Leading by 3-6", "Trailing by 3-6", "Trailing by 7+"]:
data = wp_by_time[wp_by_time['score_diff_bucket'] == scenario]
plt.plot(data['time_bucket'], data['avg_wp'],
marker='o', linewidth=2.5, markersize=6,
color=colors[scenario], label=scenario)
plt.xlabel('Minutes Remaining in 4th Quarter', fontsize=12, fontweight='bold')
plt.ylabel('Win Probability', fontsize=12, fontweight='bold')
plt.title('How Time Affects Win Probability\n4th Quarter Win Probability by Score Differential and Time Remaining',
fontsize=14, fontweight='bold', pad=20)
plt.legend(title='Game State', title_fontsize=11, fontsize=10, loc='right')
plt.grid(True, alpha=0.3, which='major')
plt.ylim(0, 1)
plt.gca().yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0%}'))
plt.text(0.02, 0.02, 'Data: nfl_data_py | 2020-2023 Seasons',
transform=plt.gca().transAxes, fontsize=8, style='italic')
plt.tight_layout()
plt.show()
Optimal Timeout Usage
Timeouts are one of the most valuable resources in football, yet they are frequently mismanaged. Each team receives three timeouts per half, and understanding when to use (or preserve) them is crucial.
Timeout Value Framework
The value of a timeout depends on:
- Time remaining: More valuable late in halves
- Score differential: More valuable when trailing
- Timeouts remaining: First timeout less valuable than last
- Field position: More valuable when in scoring position
We can estimate timeout value by comparing win probability with and without timeouts:
$$ \text{Timeout Value} = \text{WP}(\text{with timeout}) - \text{WP}(\text{without timeout}) $$
Timeout Value Analysis
#| label: timeout-value-r
#| message: false
#| warning: false
# Analyze timeout value by game situation
timeout_analysis <- pbp %>%
filter(
qtr == 4,
game_seconds_remaining <= 300, # Last 5 minutes
!is.na(wp),
!is.na(posteam_timeouts_remaining),
abs(score_differential) <= 8
) %>%
mutate(
time_bucket = case_when(
game_seconds_remaining <= 120 ~ "0-2 min",
game_seconds_remaining <= 180 ~ "2-3 min",
game_seconds_remaining <= 240 ~ "3-4 min",
TRUE ~ "4-5 min"
),
trailing = score_differential < 0
)
# Calculate WP by timeouts remaining
timeout_value_est <- timeout_analysis %>%
group_by(time_bucket, posteam_timeouts_remaining, trailing) %>%
summarise(
avg_wp = mean(wp, na.rm = TRUE),
plays = n(),
.groups = "drop"
) %>%
filter(plays >= 30)
# Calculate marginal value of each timeout
timeout_marginal <- timeout_value_est %>%
group_by(time_bucket, trailing) %>%
arrange(posteam_timeouts_remaining) %>%
mutate(
marginal_value = avg_wp - lag(avg_wp, default = first(avg_wp))
) %>%
filter(posteam_timeouts_remaining > 0)
# Display timeout value table
timeout_marginal %>%
filter(trailing == TRUE) %>%
select(-plays, -trailing) %>%
pivot_wider(
names_from = time_bucket,
values_from = marginal_value
) %>%
gt() %>%
cols_label(
posteam_timeouts_remaining = "Timeouts",
`0-2 min` = "0-2 Min",
`2-3 min` = "2-3 Min",
`3-4 min` = "3-4 Min",
`4-5 min` = "4-5 Min"
) %>%
fmt_number(
columns = -posteam_timeouts_remaining,
decimals = 3
) %>%
tab_header(
title = "Marginal Value of Timeouts (WP Increase)",
subtitle = "When Trailing in 4th Quarter"
)
#| label: timeout-value-py
#| message: false
#| warning: false
# Analyze timeout value by game situation
timeout_analysis = (pbp
.query("qtr == 4 & game_seconds_remaining <= 300 & wp.notna() & "
"posteam_timeouts_remaining.notna() & abs(score_differential) <= 8")
.copy()
)
def time_bucket(seconds):
if seconds <= 120:
return "0-2 min"
elif seconds <= 180:
return "2-3 min"
elif seconds <= 240:
return "3-4 min"
else:
return "4-5 min"
timeout_analysis['time_bucket'] = timeout_analysis['game_seconds_remaining'].apply(time_bucket)
timeout_analysis['trailing'] = timeout_analysis['score_differential'] < 0
# Calculate WP by timeouts remaining
timeout_value_est = (timeout_analysis
.groupby(['time_bucket', 'posteam_timeouts_remaining', 'trailing'])
.agg(
avg_wp=('wp', 'mean'),
plays=('wp', 'count')
)
.reset_index()
.query("plays >= 30")
)
# Calculate marginal value of each timeout
timeout_marginal = (timeout_value_est
.sort_values(['time_bucket', 'trailing', 'posteam_timeouts_remaining'])
.copy()
)
timeout_marginal['marginal_value'] = (
timeout_marginal.groupby(['time_bucket', 'trailing'])['avg_wp']
.diff()
)
# Display timeout value table (when trailing)
trailing_timeouts = (timeout_marginal
.query("trailing == True & posteam_timeouts_remaining > 0")
[['time_bucket', 'posteam_timeouts_remaining', 'marginal_value']]
.pivot(index='posteam_timeouts_remaining', columns='time_bucket', values='marginal_value')
)
print("\nMarginal Value of Timeouts (WP Increase) - When Trailing in 4th Quarter")
print("=" * 75)
print(trailing_timeouts.to_string())
Common Timeout Mistakes
#| label: timeout-mistakes-r
#| message: false
#| warning: false
# Identify games where teams had unused timeouts at end
timeout_mistakes <- pbp %>%
filter(
game_seconds_remaining <= 10,
qtr == 4,
!is.na(posteam_timeouts_remaining),
abs(score_differential) <= 8
) %>%
group_by(game_id, posteam) %>%
filter(game_seconds_remaining == min(game_seconds_remaining)) %>%
ungroup() %>%
mutate(
wasted_timeouts = posteam_timeouts_remaining,
trailing = score_differential < 0
) %>%
filter(wasted_timeouts > 0)
# Summarize by outcome
timeout_waste_summary <- timeout_mistakes %>%
group_by(trailing, wasted_timeouts) %>%
summarise(
games = n(),
avg_final_wp = mean(wp, na.rm = TRUE),
.groups = "drop"
)
timeout_waste_summary %>%
gt() %>%
cols_label(
trailing = "Team Trailing?",
wasted_timeouts = "Unused Timeouts",
games = "Games",
avg_final_wp = "Avg Final WP"
) %>%
fmt_number(
columns = avg_final_wp,
decimals = 3
) %>%
tab_header(
title = "Wasted Timeouts at End of Game",
subtitle = "One-score games, final 10 seconds (2020-2023)"
)
#| label: timeout-mistakes-py
#| message: false
#| warning: false
# Identify games where teams had unused timeouts at end
timeout_mistakes = (pbp
.query("game_seconds_remaining <= 10 & qtr == 4 & "
"posteam_timeouts_remaining.notna() & abs(score_differential) <= 8")
.copy()
)
# Get final play of each game for each team
timeout_mistakes = (timeout_mistakes
.sort_values('game_seconds_remaining')
.groupby(['game_id', 'posteam'])
.first()
.reset_index()
)
timeout_mistakes['wasted_timeouts'] = timeout_mistakes['posteam_timeouts_remaining']
timeout_mistakes['trailing'] = timeout_mistakes['score_differential'] < 0
timeout_mistakes = timeout_mistakes.query("wasted_timeouts > 0")
# Summarize by outcome
timeout_waste_summary = (timeout_mistakes
.groupby(['trailing', 'wasted_timeouts'])
.agg(
games=('game_id', 'count'),
avg_final_wp=('wp', 'mean')
)
.reset_index()
)
print("\nWasted Timeouts at End of Game")
print("One-score games, final 10 seconds (2020-2023)")
print("=" * 65)
print(timeout_waste_summary.to_string(index=False))
The Timeout Waste Problem
Analysis shows that in close games, teams end with unused timeouts approximately 15% of the time. This represents a significant strategic failure, as those timeouts could have been used to stop the clock, preserve time for additional plays, or ice the kicker.Two-Minute Drill Strategy
The two-minute drill is one of football's most exciting scenarios. Teams trailing or tied must balance the competing demands of moving the ball efficiently while preserving time for additional plays.
Two-Minute Drill Success Rates
#| label: two-minute-drill-r
#| message: false
#| warning: false
# Analyze two-minute drill drives
two_min_drives <- pbp %>%
filter(
qtr %in% c(2, 4),
game_seconds_remaining <= 120,
game_seconds_remaining > 0,
!is.na(drive),
!is.na(score_differential)
) %>%
group_by(game_id, drive) %>%
summarise(
half = first(qtr),
start_time = max(game_seconds_remaining),
end_time = min(game_seconds_remaining),
start_yardline = first(yardline_100),
end_yardline = last(yardline_100),
start_score_diff = first(score_differential),
points_scored = sum(
case_when(
sp == 1 & (touchdown == 1 | field_goal_result == "made") ~
(touchdown * 6 + field_goal_result == "made" * 3),
TRUE ~ 0
),
na.rm = TRUE
),
got_points = points_scored > 0,
plays = n(),
timeouts_used = first(posteam_timeouts_remaining, na_rm = TRUE) -
last(posteam_timeouts_remaining, na_rm = TRUE),
.groups = "drop"
) %>%
filter(
start_time >= 60, # Started with at least 1 minute
start_yardline <= 90 # Not already in field goal range
)
# Calculate success rates by starting situation
two_min_success <- two_min_drives %>%
mutate(
field_position = case_when(
start_yardline >= 75 ~ "Own 25 or worse",
start_yardline >= 50 ~ "Own 25 to midfield",
TRUE ~ "Opponent territory"
),
time_available = case_when(
start_time >= 100 ~ "100+ seconds",
start_time >= 80 ~ "80-99 seconds",
TRUE ~ "60-79 seconds"
)
) %>%
group_by(field_position, time_available) %>%
summarise(
drives = n(),
success_rate = mean(got_points),
avg_points = mean(points_scored),
.groups = "drop"
)
two_min_success %>%
gt() %>%
cols_label(
field_position = "Starting Field Position",
time_available = "Time Available",
drives = "Drives",
success_rate = "Success Rate",
avg_points = "Avg Points"
) %>%
fmt_percent(
columns = success_rate,
decimals = 1
) %>%
fmt_number(
columns = c(avg_points),
decimals = 2
) %>%
tab_header(
title = "Two-Minute Drill Success Rates",
subtitle = "End of 2nd and 4th quarters, 2020-2023"
)
#| label: two-minute-drill-py
#| message: false
#| warning: false
# Analyze two-minute drill drives
two_min_data = (pbp
.query("qtr in [2, 4] & game_seconds_remaining <= 120 & "
"game_seconds_remaining > 0 & drive.notna() & score_differential.notna()")
.copy()
)
# Calculate points scored on each drive
def calc_points(group):
points = 0
if group['touchdown'].sum() > 0:
points += 6
if (group['field_goal_result'] == 'made').sum() > 0:
points += 3
return points
two_min_drives = []
for (game_id, drive_num), group in two_min_data.groupby(['game_id', 'drive']):
drive_info = {
'game_id': game_id,
'drive': drive_num,
'half': group['qtr'].iloc[0],
'start_time': group['game_seconds_remaining'].max(),
'end_time': group['game_seconds_remaining'].min(),
'start_yardline': group['yardline_100'].iloc[0],
'end_yardline': group['yardline_100'].iloc[-1],
'start_score_diff': group['score_differential'].iloc[0],
'plays': len(group),
'got_points': (group['touchdown'].sum() > 0) or ((group['field_goal_result'] == 'made').sum() > 0),
'points_scored': calc_points(group)
}
two_min_drives.append(drive_info)
two_min_drives = pd.DataFrame(two_min_drives)
# Filter to relevant drives
two_min_drives = two_min_drives.query("start_time >= 60 & start_yardline <= 90")
# Categorize drives
def categorize_field_position(yardline):
if yardline >= 75:
return "Own 25 or worse"
elif yardline >= 50:
return "Own 25 to midfield"
else:
return "Opponent territory"
def categorize_time(seconds):
if seconds >= 100:
return "100+ seconds"
elif seconds >= 80:
return "80-99 seconds"
else:
return "60-79 seconds"
two_min_drives['field_position'] = two_min_drives['start_yardline'].apply(categorize_field_position)
two_min_drives['time_available'] = two_min_drives['start_time'].apply(categorize_time)
# Calculate success rates
two_min_success = (two_min_drives
.groupby(['field_position', 'time_available'])
.agg(
drives=('game_id', 'count'),
success_rate=('got_points', 'mean'),
avg_points=('points_scored', 'mean')
)
.reset_index()
)
print("\nTwo-Minute Drill Success Rates")
print("End of 2nd and 4th quarters, 2020-2023")
print("=" * 80)
print(two_min_success.to_string(index=False))
Play Type Selection in Two-Minute Drill
#| label: fig-two-minute-plays-r
#| fig-cap: "Play selection and EPA in two-minute situations"
#| fig-width: 12
#| fig-height: 6
# Analyze play type in two-minute situations
two_min_plays <- pbp %>%
filter(
qtr %in% c(2, 4),
game_seconds_remaining <= 120,
game_seconds_remaining > 0,
!is.na(epa),
play_type %in% c("pass", "run"),
down <= 3
) %>%
mutate(
time_bucket = case_when(
game_seconds_remaining > 90 ~ "91-120 sec",
game_seconds_remaining > 60 ~ "61-90 sec",
game_seconds_remaining > 30 ~ "31-60 sec",
TRUE ~ "0-30 sec"
)
)
# Calculate play type usage and EPA
play_type_summary <- two_min_plays %>%
group_by(time_bucket, play_type) %>%
summarise(
plays = n(),
avg_epa = mean(epa, na.rm = TRUE),
success_rate = mean(epa > 0, na.rm = TRUE),
.groups = "drop"
) %>%
group_by(time_bucket) %>%
mutate(play_pct = plays / sum(plays))
# Create visualization
ggplot(play_type_summary, aes(x = time_bucket, y = play_pct, fill = play_type)) +
geom_col(position = "stack") +
geom_text(
aes(label = sprintf("%.1f%%", play_pct * 100)),
position = position_stack(vjust = 0.5),
color = "white",
fontface = "bold",
size = 4
) +
scale_fill_manual(
values = c("pass" = "#00BFC4", "run" = "#F8766D"),
labels = c("Pass", "Run")
) +
scale_y_continuous(labels = scales::percent_format()) +
labs(
title = "Play Type Selection in Two-Minute Situations",
subtitle = "Pass/Run distribution by time remaining (2020-2023)",
x = "Time Remaining",
y = "Percentage of Plays",
fill = "Play Type",
caption = "Data: nflfastR"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
legend.position = "top",
panel.grid.major.x = element_blank()
)
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
#| label: fig-two-minute-plays-py
#| fig-cap: "Play selection and EPA in two-minute situations - Python"
#| fig-width: 12
#| fig-height: 6
# Analyze play type in two-minute situations
two_min_plays = (pbp
.query("qtr in [2, 4] & game_seconds_remaining <= 120 & "
"game_seconds_remaining > 0 & epa.notna() & "
"play_type in ['pass', 'run'] & down <= 3")
.copy()
)
def time_bucket(seconds):
if seconds > 90:
return "91-120 sec"
elif seconds > 60:
return "61-90 sec"
elif seconds > 30:
return "31-60 sec"
else:
return "0-30 sec"
two_min_plays['time_bucket'] = two_min_plays['game_seconds_remaining'].apply(time_bucket)
# Calculate play type usage
play_type_summary = (two_min_plays
.groupby(['time_bucket', 'play_type'])
.agg(
plays=('epa', 'count'),
avg_epa=('epa', 'mean'),
success_rate=('epa', lambda x: (x > 0).mean())
)
.reset_index()
)
play_type_summary['play_pct'] = (play_type_summary
.groupby('time_bucket')['plays']
.transform(lambda x: play_type_summary.loc[x.index, 'plays'] / x.sum())
)
# Create visualization
time_order = ["91-120 sec", "61-90 sec", "31-60 sec", "0-30 sec"]
play_types = ['run', 'pass']
fig, ax = plt.subplots(figsize=(12, 6))
bottom = np.zeros(len(time_order))
colors = {'pass': '#00BFC4', 'run': '#F8766D'}
for play_type in play_types:
data = play_type_summary[play_type_summary['play_type'] == play_type]
values = [data[data['time_bucket'] == t]['play_pct'].values[0]
if len(data[data['time_bucket'] == t]) > 0 else 0
for t in time_order]
ax.bar(time_order, values, bottom=bottom, label=play_type.title(),
color=colors[play_type])
# Add percentage labels
for i, (v, b) in enumerate(zip(values, bottom)):
if v > 0.05: # Only show label if segment is large enough
ax.text(i, b + v/2, f'{v*100:.1f}%',
ha='center', va='center', color='white',
fontweight='bold', fontsize=11)
bottom += values
ax.set_xlabel('Time Remaining', fontsize=12, fontweight='bold')
ax.set_ylabel('Percentage of Plays', fontsize=12, fontweight='bold')
ax.set_title('Play Type Selection in Two-Minute Situations\nPass/Run distribution by time remaining (2020-2023)',
fontsize=14, fontweight='bold', pad=20)
ax.legend(title='Play Type', loc='upper left')
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0%}'))
plt.text(0.98, 0.02, 'Data: nfl_data_py',
transform=ax.transAxes, ha='right', fontsize=8, style='italic')
plt.tight_layout()
plt.show()
Two-Minute Drill Best Practices
1. **Incomplete passes are not always bad**: They stop the clock without using timeouts 2. **Run plays after first downs**: The clock stops on first downs, making runs more viable 3. **Save timeouts for end**: Often better to use plays to stop clock early 4. **Get out of bounds**: Especially valuable when low on timeouts 5. **Spike strategically**: Only when timeouts are exhausted or situation is direEnd-of-Half Decisions
End-of-half scenarios present unique strategic challenges. Teams must decide whether to attempt to score or run out the clock, balancing opportunity against risk.
Expected Points Before Halftime
#| label: end-half-analysis-r
#| message: false
#| warning: false
# Analyze end-of-half possessions
end_half_drives <- pbp %>%
filter(
qtr == 2,
game_seconds_remaining <= 120,
game_seconds_remaining > 0,
!is.na(drive)
) %>%
group_by(game_id, drive) %>%
summarise(
start_time = max(game_seconds_remaining),
start_yardline = first(yardline_100),
start_score_diff = first(score_differential),
timeouts = first(posteam_timeouts_remaining),
points_scored = sum(
case_when(
sp == 1 & touchdown == 1 ~ 6,
sp == 1 & field_goal_result == "made" ~ 3,
TRUE ~ 0
),
na.rm = TRUE
),
plays_run = n(),
turnover = sum(interception == 1 | fumble_lost == 1, na.rm = TRUE) > 0,
.groups = "drop"
) %>%
filter(start_yardline <= 80) # Reasonable starting position
# Calculate expected points by situation
end_half_ep <- end_half_drives %>%
mutate(
time_category = case_when(
start_time >= 90 ~ "90-120 sec",
start_time >= 60 ~ "60-89 sec",
start_time >= 30 ~ "30-59 sec",
TRUE ~ "0-29 sec"
),
field_pos_category = case_when(
start_yardline >= 60 ~ "Own half",
start_yardline >= 40 ~ "Midfield area",
TRUE ~ "Opp territory"
)
) %>%
group_by(time_category, field_pos_category) %>%
summarise(
drives = n(),
avg_points = mean(points_scored),
score_pct = mean(points_scored > 0),
turnover_pct = mean(turnover),
.groups = "drop"
) %>%
filter(drives >= 20)
end_half_ep %>%
gt() %>%
cols_label(
time_category = "Time Remaining",
field_pos_category = "Field Position",
drives = "Drives",
avg_points = "Avg Points",
score_pct = "Score %",
turnover_pct = "Turnover %"
) %>%
fmt_number(
columns = c(avg_points),
decimals = 2
) %>%
fmt_percent(
columns = c(score_pct, turnover_pct),
decimals = 1
) %>%
tab_header(
title = "End-of-Half Expected Points",
subtitle = "By starting time and field position (2020-2023)"
)
#| label: end-half-analysis-py
#| message: false
#| warning: false
# Analyze end-of-half possessions
end_half_data = (pbp
.query("qtr == 2 & game_seconds_remaining <= 120 & "
"game_seconds_remaining > 0 & drive.notna()")
.copy()
)
end_half_drives = []
for (game_id, drive_num), group in end_half_data.groupby(['game_id', 'drive']):
points = 0
if group['touchdown'].sum() > 0:
points += 6
if (group['field_goal_result'] == 'made').sum() > 0:
points += 3
drive_info = {
'game_id': game_id,
'drive': drive_num,
'start_time': group['game_seconds_remaining'].max(),
'start_yardline': group['yardline_100'].iloc[0],
'start_score_diff': group['score_differential'].iloc[0],
'timeouts': group['posteam_timeouts_remaining'].iloc[0],
'points_scored': points,
'plays_run': len(group),
'turnover': (group['interception'].sum() > 0) or (group['fumble_lost'].sum() > 0)
}
end_half_drives.append(drive_info)
end_half_drives = pd.DataFrame(end_half_drives)
end_half_drives = end_half_drives.query("start_yardline <= 80")
# Calculate expected points by situation
def categorize_time_eoh(seconds):
if seconds >= 90:
return "90-120 sec"
elif seconds >= 60:
return "60-89 sec"
elif seconds >= 30:
return "30-59 sec"
else:
return "0-29 sec"
def categorize_field_pos_eoh(yardline):
if yardline >= 60:
return "Own half"
elif yardline >= 40:
return "Midfield area"
else:
return "Opp territory"
end_half_drives['time_category'] = end_half_drives['start_time'].apply(categorize_time_eoh)
end_half_drives['field_pos_category'] = end_half_drives['start_yardline'].apply(categorize_field_pos_eoh)
end_half_ep = (end_half_drives
.groupby(['time_category', 'field_pos_category'])
.agg(
drives=('game_id', 'count'),
avg_points=('points_scored', 'mean'),
score_pct=('points_scored', lambda x: (x > 0).mean()),
turnover_pct=('turnover', 'mean')
)
.reset_index()
.query("drives >= 20")
)
print("\nEnd-of-Half Expected Points")
print("By starting time and field position (2020-2023)")
print("=" * 85)
print(end_half_ep.to_string(index=False))
The "Take a Knee" Decision
One critical end-of-half decision is whether to attempt to score or take a knee. This depends on:
- Time remaining: Less time favors taking a knee
- Timeouts available: More timeouts enable scoring attempts
- Field position: Better field position favors attempting to score
- Score differential: Leading teams more conservative
- Risk of turnover: Interception risk vs reward
Case Study: Conservative Play-Calling Mistakes
In the 2020 Super Bowl, the 49ers received the ball with 1:40 left in the first half on their own 20-yard line with all three timeouts. They ran three conservative plays and punted. Analytics suggest this was a significant mistake—the expected value of attempting to score far exceeded the minimal risk of a turnover leading to opponent points.End-of-Game Scenarios
End-of-game situations require precise clock management and strategic decision-making.
Victory Formation Analysis
#| label: victory-formation-r
#| message: false
#| warning: false
# Analyze when teams use victory formation
victory_formation <- pbp %>%
filter(
qtr == 4,
!is.na(score_differential),
!is.na(game_seconds_remaining)
) %>%
mutate(
kneel = grepl("kneel", desc, ignore.case = TRUE),
winning = score_differential > 0
) %>%
filter(kneel == TRUE)
# Calculate when teams kneel
kneel_situations <- victory_formation %>%
group_by(winning) %>%
summarise(
kneels = n(),
avg_score_diff = mean(score_differential),
avg_time_remaining = mean(game_seconds_remaining),
min_time = min(game_seconds_remaining),
max_time = max(game_seconds_remaining),
.groups = "drop"
)
kneel_situations %>%
gt() %>%
cols_label(
winning = "Winning?",
kneels = "Kneels",
avg_score_diff = "Avg Score Diff",
avg_time_remaining = "Avg Time (sec)",
min_time = "Min Time (sec)",
max_time = "Max Time (sec)"
) %>%
fmt_number(
columns = c(avg_score_diff, avg_time_remaining, min_time, max_time),
decimals = 1
) %>%
tab_header(
title = "Victory Formation Usage",
subtitle = "4th Quarter, 2020-2023"
)
#| label: victory-formation-py
#| message: false
#| warning: false
# Analyze when teams use victory formation
victory_formation = (pbp
.query("qtr == 4 & score_differential.notna() & game_seconds_remaining.notna()")
.copy()
)
victory_formation['kneel'] = victory_formation['desc'].fillna('').str.contains('kneel', case=False)
victory_formation['winning'] = victory_formation['score_differential'] > 0
victory_formation = victory_formation.query("kneel == True")
# Calculate when teams kneel
kneel_situations = (victory_formation
.groupby('winning')
.agg(
kneels=('desc', 'count'),
avg_score_diff=('score_differential', 'mean'),
avg_time_remaining=('game_seconds_remaining', 'mean'),
min_time=('game_seconds_remaining', 'min'),
max_time=('game_seconds_remaining', 'max')
)
.reset_index()
)
print("\nVictory Formation Usage")
print("4th Quarter, 2020-2023")
print("=" * 75)
print(kneel_situations.to_string(index=False))
Prevent Defense Effectiveness
Prevent defense is often used late in games to protect leads. However, its effectiveness is debatable.
#| label: fig-prevent-defense-r
#| fig-cap: "Defensive EPA allowed in late-game situations"
#| fig-width: 10
#| fig-height: 6
# Analyze defensive performance in late-game situations
late_game_defense <- pbp %>%
filter(
qtr == 4,
game_seconds_remaining <= 300,
!is.na(epa),
!is.na(defteam),
play_type %in% c("pass", "run"),
score_differential >= 1, # Defense is winning
score_differential <= 14 # Within 2 scores
) %>%
mutate(
time_bucket = case_when(
game_seconds_remaining <= 60 ~ "0-1 min",
game_seconds_remaining <= 120 ~ "1-2 min",
game_seconds_remaining <= 180 ~ "2-3 min",
game_seconds_remaining <= 240 ~ "3-4 min",
TRUE ~ "4-5 min"
),
lead_size = case_when(
score_differential <= 3 ~ "1-3 points",
score_differential <= 7 ~ "4-7 points",
TRUE ~ "8-14 points"
)
)
# Calculate defensive EPA allowed
def_epa_summary <- late_game_defense %>%
group_by(time_bucket, lead_size, play_type) %>%
summarise(
plays = n(),
avg_epa_allowed = mean(epa, na.rm = TRUE),
success_rate_allowed = mean(epa > 0, na.rm = TRUE),
.groups = "drop"
) %>%
filter(plays >= 30)
# Visualize pass defense EPA
def_epa_summary %>%
filter(play_type == "pass") %>%
ggplot(aes(x = time_bucket, y = avg_epa_allowed, fill = lead_size)) +
geom_col(position = "dodge") +
geom_hline(yintercept = 0, linetype = "dashed") +
scale_fill_brewer(palette = "Set2") +
labs(
title = "EPA Allowed on Pass Plays in Late-Game Situations",
subtitle = "Defending team holding lead (2020-2023)",
x = "Time Remaining",
y = "Average EPA Allowed per Play",
fill = "Lead Size",
caption = "Data: nflfastR | Positive EPA favors offense"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
legend.position = "top",
panel.grid.major.x = element_blank()
)
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
#| label: fig-prevent-defense-py
#| fig-cap: "Defensive EPA allowed in late-game situations - Python"
#| fig-width: 10
#| fig-height: 6
# Analyze defensive performance in late-game situations
late_game_defense = (pbp
.query("qtr == 4 & game_seconds_remaining <= 300 & epa.notna() & "
"defteam.notna() & play_type in ['pass', 'run'] & "
"score_differential >= 1 & score_differential <= 14")
.copy()
)
def time_bucket_late(seconds):
if seconds <= 60:
return "0-1 min"
elif seconds <= 120:
return "1-2 min"
elif seconds <= 180:
return "2-3 min"
elif seconds <= 240:
return "3-4 min"
else:
return "4-5 min"
def lead_size_category(diff):
if diff <= 3:
return "1-3 points"
elif diff <= 7:
return "4-7 points"
else:
return "8-14 points"
late_game_defense['time_bucket'] = late_game_defense['game_seconds_remaining'].apply(time_bucket_late)
late_game_defense['lead_size'] = late_game_defense['score_differential'].apply(lead_size_category)
# Calculate defensive EPA allowed
def_epa_summary = (late_game_defense
.groupby(['time_bucket', 'lead_size', 'play_type'])
.agg(
plays=('epa', 'count'),
avg_epa_allowed=('epa', 'mean'),
success_rate_allowed=('epa', lambda x: (x > 0).mean())
)
.reset_index()
.query("plays >= 30")
)
# Visualize pass defense EPA
pass_data = def_epa_summary.query("play_type == 'pass'")
time_order = ["4-5 min", "3-4 min", "2-3 min", "1-2 min", "0-1 min"]
lead_sizes = ["1-3 points", "4-7 points", "8-14 points"]
colors = ['#66C2A5', '#FC8D62', '#8DA0CB']
fig, ax = plt.subplots(figsize=(10, 6))
x = np.arange(len(time_order))
width = 0.25
for i, lead in enumerate(lead_sizes):
data = pass_data[pass_data['lead_size'] == lead]
values = [data[data['time_bucket'] == t]['avg_epa_allowed'].values[0]
if len(data[data['time_bucket'] == t]) > 0 else 0
for t in time_order]
ax.bar(x + i * width, values, width, label=lead, color=colors[i])
ax.axhline(y=0, color='black', linestyle='--', alpha=0.5)
ax.set_xlabel('Time Remaining', fontsize=12, fontweight='bold')
ax.set_ylabel('Average EPA Allowed per Play', fontsize=12, fontweight='bold')
ax.set_title('EPA Allowed on Pass Plays in Late-Game Situations\nDefending team holding lead (2020-2023)',
fontsize=14, fontweight='bold', pad=20)
ax.set_xticks(x + width)
ax.set_xticklabels(time_order)
ax.legend(title='Lead Size', loc='upper left')
plt.text(0.98, 0.02, 'Data: nfl_data_py | Positive EPA favors offense',
transform=ax.transAxes, ha='right', fontsize=8, style='italic')
plt.tight_layout()
plt.show()
The Prevent Defense Paradox
Analysis shows that prevent defense often allows significantly higher EPA per play than standard defense. The name "prevent defense" is ironic—it often only "prevents you from winning." Teams should carefully evaluate whether giving up chunk plays is worth preventing the big play.Hurry-Up Offense vs Running Clock
The decision to use hurry-up (no-huddle) offense versus a normal pace affects both clock management and play efficiency.
Hurry-Up Offense Analysis
#| label: hurryup-analysis-r
#| message: false
#| warning: false
# Analyze hurry-up offense (identified by short time between plays)
hurryup_analysis <- pbp %>%
filter(
!is.na(epa),
play_type %in% c("pass", "run"),
qtr %in% c(1, 2, 3, 4)
) %>%
arrange(game_id, game_seconds_remaining) %>%
group_by(game_id, drive) %>%
mutate(
time_since_last = lag(game_seconds_remaining) - game_seconds_remaining,
hurry_up = time_since_last <= 15 & time_since_last > 0 # Play within 15 seconds
) %>%
ungroup() %>%
filter(!is.na(hurry_up))
# Calculate hurry-up vs normal pace statistics
pace_comparison <- hurryup_analysis %>%
group_by(hurry_up, play_type) %>%
summarise(
plays = n(),
avg_epa = mean(epa, na.rm = TRUE),
success_rate = mean(epa > 0, na.rm = TRUE),
avg_yards = mean(yards_gained, na.rm = TRUE),
.groups = "drop"
)
pace_comparison %>%
mutate(pace = ifelse(hurry_up, "Hurry-Up", "Normal")) %>%
select(-hurry_up) %>%
gt() %>%
cols_label(
pace = "Pace",
play_type = "Play Type",
plays = "Plays",
avg_epa = "Avg EPA",
success_rate = "Success Rate",
avg_yards = "Avg Yards"
) %>%
fmt_number(
columns = c(avg_epa, avg_yards),
decimals = 2
) %>%
fmt_percent(
columns = success_rate,
decimals = 1
) %>%
tab_header(
title = "Hurry-Up vs Normal Pace Offense",
subtitle = "EPA and efficiency metrics (2020-2023)"
)
#| label: hurryup-analysis-py
#| message: false
#| warning: false
# Analyze hurry-up offense
hurryup_data = (pbp
.query("epa.notna() & play_type in ['pass', 'run'] & qtr in [1, 2, 3, 4]")
[['game_id', 'drive', 'game_seconds_remaining', 'epa', 'play_type', 'yards_gained']]
.sort_values(['game_id', 'drive', 'game_seconds_remaining'], ascending=[True, True, False])
.copy()
)
# Calculate time since last play
hurryup_data['time_since_last'] = (
hurryup_data.groupby(['game_id', 'drive'])['game_seconds_remaining']
.diff()
.abs()
)
# Identify hurry-up plays (within 15 seconds)
hurryup_data['hurry_up'] = (
(hurryup_data['time_since_last'] <= 15) &
(hurryup_data['time_since_last'] > 0)
)
hurryup_data = hurryup_data.dropna(subset=['hurry_up'])
# Calculate hurry-up vs normal pace statistics
pace_comparison = (hurryup_data
.groupby(['hurry_up', 'play_type'])
.agg(
plays=('epa', 'count'),
avg_epa=('epa', 'mean'),
success_rate=('epa', lambda x: (x > 0).mean()),
avg_yards=('yards_gained', 'mean')
)
.reset_index()
)
pace_comparison['pace'] = pace_comparison['hurry_up'].map({True: 'Hurry-Up', False: 'Normal'})
pace_comparison = pace_comparison.drop('hurry_up', axis=1)
print("\nHurry-Up vs Normal Pace Offense")
print("EPA and efficiency metrics (2020-2023)")
print("=" * 80)
print(pace_comparison[['pace', 'play_type', 'plays', 'avg_epa', 'success_rate', 'avg_yards']].to_string(index=False))
Clock-Stopping Plays and Strategies
Understanding which plays stop the clock and how to maximize their value is essential for effective clock management.
Clock-Stopping Mechanisms
- Incomplete passes: Most common clock-stopper
- Out of bounds: Carrier must step out before forward progress stopped
- First downs: Clock stops briefly until ball is set (inside 5 min of half)
- Timeouts: Three per half
- Two-minute warning: Automatic timeout
- Penalties: Some penalties stop clock
- Change of possession: Turnovers, punts, etc.
- Scoring plays: Touchdowns, field goals
#| label: clock-stopping-r
#| message: false
#| warning: false
# Analyze clock-stopping plays
clock_management <- pbp %>%
filter(
qtr %in% c(2, 4),
game_seconds_remaining <= 120,
!is.na(epa),
play_type %in% c("pass", "run")
) %>%
mutate(
incomplete = complete_pass == 0 & play_type == "pass",
out_of_bounds = str_detect(desc, "out of bounds|pushed ob|ran ob"),
clock_stopped = incomplete | out_of_bounds,
trailing = score_differential < 0
)
# Calculate frequency and efficiency of clock-stopping plays
clock_stop_summary <- clock_management %>%
filter(trailing == TRUE) %>%
group_by(play_type, clock_stopped) %>%
summarise(
plays = n(),
avg_epa = mean(epa, na.rm = TRUE),
avg_yards = mean(yards_gained, na.rm = TRUE),
success_rate = mean(epa > 0, na.rm = TRUE),
.groups = "drop"
)
clock_stop_summary %>%
mutate(outcome = ifelse(clock_stopped, "Clock Stopped", "Clock Running")) %>%
select(-clock_stopped) %>%
gt() %>%
cols_label(
play_type = "Play Type",
outcome = "Clock Outcome",
plays = "Plays",
avg_epa = "Avg EPA",
avg_yards = "Avg Yards",
success_rate = "Success Rate"
) %>%
fmt_number(
columns = c(avg_epa, avg_yards),
decimals = 2
) %>%
fmt_percent(
columns = success_rate,
decimals = 1
) %>%
tab_header(
title = "Clock-Stopping Play Analysis",
subtitle = "When trailing, final 2 minutes (2020-2023)"
)
#| label: clock-stopping-py
#| message: false
#| warning: false
# Analyze clock-stopping plays
clock_management = (pbp
.query("qtr in [2, 4] & game_seconds_remaining <= 120 & "
"epa.notna() & play_type in ['pass', 'run']")
.copy()
)
clock_management['incomplete'] = (
(clock_management['complete_pass'] == 0) &
(clock_management['play_type'] == 'pass')
)
clock_management['out_of_bounds'] = (
clock_management['desc'].fillna('').str.contains('out of bounds|pushed ob|ran ob', case=False)
)
clock_management['clock_stopped'] = (
clock_management['incomplete'] | clock_management['out_of_bounds']
)
clock_management['trailing'] = clock_management['score_differential'] < 0
# Calculate frequency and efficiency of clock-stopping plays
clock_stop_summary = (clock_management
.query("trailing == True")
.groupby(['play_type', 'clock_stopped'])
.agg(
plays=('epa', 'count'),
avg_epa=('epa', 'mean'),
avg_yards=('yards_gained', 'mean'),
success_rate=('epa', lambda x: (x > 0).mean())
)
.reset_index()
)
clock_stop_summary['outcome'] = clock_stop_summary['clock_stopped'].map({
True: 'Clock Stopped',
False: 'Clock Running'
})
print("\nClock-Stopping Play Analysis")
print("When trailing, final 2 minutes (2020-2023)")
print("=" * 85)
print(clock_stop_summary[['play_type', 'outcome', 'plays', 'avg_epa', 'avg_yards', 'success_rate']].to_string(index=False))
Coach Clock Management Evaluation
We can evaluate individual coaches' clock management decisions using win probability models.
Measuring Clock Management Quality
#| label: coach-evaluation-r
#| message: false
#| warning: false
#| cache: true
# Load roster data for coach information
rosters <- load_rosters(2020:2023)
# Identify potential clock management mistakes
clock_mistakes <- pbp %>%
filter(
qtr == 4,
game_seconds_remaining <= 120,
game_seconds_remaining > 0,
!is.na(wp),
!is.na(wpa)
) %>%
mutate(
# Identify plays with large negative WPA
major_mistake = wpa < -0.05 & abs(score_differential) <= 8,
# Identify timeout usage issues
timeout_used = timeout == 1,
# Identify delay of game penalties
delay_of_game = str_detect(desc, "Delay of Game")
)
# Summarize mistakes by team
team_clock_mgmt <- clock_mistakes %>%
group_by(season, posteam) %>%
summarise(
plays = n(),
major_mistakes = sum(major_mistake, na.rm = TRUE),
mistake_rate = mean(major_mistake, na.rm = TRUE),
avg_wpa = mean(wpa, na.rm = TRUE),
delay_penalties = sum(delay_of_game, na.rm = TRUE),
.groups = "drop"
) %>%
filter(plays >= 20)
# Top 10 best and worst teams at clock management
best_clock_mgmt <- team_clock_mgmt %>%
group_by(posteam) %>%
summarise(
seasons = n(),
total_plays = sum(plays),
avg_mistake_rate = mean(mistake_rate),
avg_wpa = mean(avg_wpa),
.groups = "drop"
) %>%
filter(total_plays >= 50) %>%
arrange(avg_mistake_rate) %>%
slice_head(n = 5)
worst_clock_mgmt <- team_clock_mgmt %>%
group_by(posteam) %>%
summarise(
seasons = n(),
total_plays = sum(plays),
avg_mistake_rate = mean(mistake_rate),
avg_wpa = mean(avg_wpa),
.groups = "drop"
) %>%
filter(total_plays >= 50) %>%
arrange(desc(avg_mistake_rate)) %>%
slice_head(n = 5)
# Display results
bind_rows(
best_clock_mgmt %>% mutate(category = "Best"),
worst_clock_mgmt %>% mutate(category = "Worst")
) %>%
gt() %>%
cols_label(
category = "Category",
posteam = "Team",
seasons = "Seasons",
total_plays = "Plays",
avg_mistake_rate = "Mistake Rate",
avg_wpa = "Avg WPA"
) %>%
fmt_percent(
columns = avg_mistake_rate,
decimals = 1
) %>%
fmt_number(
columns = avg_wpa,
decimals = 4
) %>%
tab_header(
title = "Clock Management Quality by Team",
subtitle = "Final 2 minutes of 4th quarter (2020-2023)"
) %>%
data_color(
columns = avg_mistake_rate,
colors = scales::col_numeric(
palette = c("#006400", "#FFFFFF", "#8B0000"),
domain = c(0, max(c(best_clock_mgmt$avg_mistake_rate, worst_clock_mgmt$avg_mistake_rate)))
)
)
#| label: coach-evaluation-py
#| message: false
#| warning: false
# Identify potential clock management mistakes
clock_mistakes = (pbp
.query("qtr == 4 & game_seconds_remaining <= 120 & game_seconds_remaining > 0 & "
"wp.notna() & wpa.notna()")
.copy()
)
# Identify plays with large negative WPA
clock_mistakes['major_mistake'] = (
(clock_mistakes['wpa'] < -0.05) &
(abs(clock_mistakes['score_differential']) <= 8)
)
# Identify timeout usage
clock_mistakes['timeout_used'] = clock_mistakes['timeout'] == 1
# Identify delay of game penalties
clock_mistakes['delay_of_game'] = (
clock_mistakes['desc'].fillna('').str.contains('Delay of Game', case=False)
)
# Summarize mistakes by team
team_clock_mgmt = (clock_mistakes
.groupby(['season', 'posteam'])
.agg(
plays=('play_id', 'count'),
major_mistakes=('major_mistake', 'sum'),
mistake_rate=('major_mistake', 'mean'),
avg_wpa=('wpa', 'mean'),
delay_penalties=('delay_of_game', 'sum')
)
.reset_index()
.query("plays >= 20")
)
# Top 5 best and worst teams at clock management
overall_team_stats = (team_clock_mgmt
.groupby('posteam')
.agg(
seasons=('season', 'count'),
total_plays=('plays', 'sum'),
avg_mistake_rate=('mistake_rate', 'mean'),
avg_wpa=('avg_wpa', 'mean')
)
.reset_index()
.query("total_plays >= 50")
)
best_clock_mgmt = (overall_team_stats
.nsmallest(5, 'avg_mistake_rate')
.assign(category='Best')
)
worst_clock_mgmt = (overall_team_stats
.nlargest(5, 'avg_mistake_rate')
.assign(category='Worst')
)
# Display results
results = pd.concat([best_clock_mgmt, worst_clock_mgmt])
print("\nClock Management Quality by Team")
print("Final 2 minutes of 4th quarter (2020-2023)")
print("=" * 90)
print(results[['category', 'posteam', 'seasons', 'total_plays', 'avg_mistake_rate', 'avg_wpa']].to_string(index=False))
Specific Clock Management Scenarios
#| label: fig-coach-scenarios-r
#| fig-cap: "Win probability impact of clock management decisions"
#| fig-width: 12
#| fig-height: 8
# Analyze specific clock management scenarios
scenarios <- pbp %>%
filter(
qtr == 4,
game_seconds_remaining <= 120,
abs(score_differential) <= 8,
!is.na(wpa)
) %>%
mutate(
scenario = case_when(
timeout == 1 & score_differential > 0 ~ "Timeout when leading",
timeout == 1 & score_differential < 0 ~ "Timeout when trailing",
play_type == "run" & score_differential > 3 ~ "Run when up > 3",
play_type == "pass" & score_differential > 3 &
game_seconds_remaining > 60 ~ "Pass when up > 3",
str_detect(desc, "Delay of Game") ~ "Delay of Game",
TRUE ~ "Other"
)
) %>%
filter(scenario != "Other")
# Calculate WPA by scenario
scenario_wpa <- scenarios %>%
group_by(scenario, posteam) %>%
summarise(
occurrences = n(),
avg_wpa = mean(wpa, na.rm = TRUE),
negative_wpa_pct = mean(wpa < 0, na.rm = TRUE),
.groups = "drop"
) %>%
filter(occurrences >= 10)
# Visualize
scenario_summary <- scenarios %>%
group_by(scenario) %>%
summarise(
occurrences = n(),
avg_wpa = mean(wpa, na.rm = TRUE),
se_wpa = sd(wpa, na.rm = TRUE) / sqrt(n()),
.groups = "drop"
)
ggplot(scenario_summary, aes(x = reorder(scenario, avg_wpa), y = avg_wpa)) +
geom_col(aes(fill = avg_wpa > 0)) +
geom_errorbar(
aes(ymin = avg_wpa - se_wpa, ymax = avg_wpa + se_wpa),
width = 0.3
) +
geom_hline(yintercept = 0, linetype = "dashed") +
scale_fill_manual(values = c("TRUE" = "#006400", "FALSE" = "#8B0000")) +
coord_flip() +
labs(
title = "Win Probability Impact of Clock Management Decisions",
subtitle = "Final 2 minutes of close games (2020-2023)",
x = "Scenario",
y = "Average WPA",
caption = "Data: nflfastR | Error bars show standard error"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold", size = 14),
legend.position = "none",
panel.grid.major.y = element_blank()
)
📊 Visualization Output
The code above generates a visualization. To see the output, run this code in your R or Python environment. The resulting plot will help illustrate the concepts discussed in this section.
#| label: fig-coach-scenarios-py
#| fig-cap: "Win probability impact of clock management decisions - Python"
#| fig-width: 12
#| fig-height: 8
# Analyze specific clock management scenarios
scenarios = (pbp
.query("qtr == 4 & game_seconds_remaining <= 120 & "
"abs(score_differential) <= 8 & wpa.notna()")
.copy()
)
def categorize_scenario(row):
if row['timeout'] == 1 and row['score_differential'] > 0:
return "Timeout when leading"
elif row['timeout'] == 1 and row['score_differential'] < 0:
return "Timeout when trailing"
elif row['play_type'] == 'run' and row['score_differential'] > 3:
return "Run when up > 3"
elif (row['play_type'] == 'pass' and row['score_differential'] > 3 and
row['game_seconds_remaining'] > 60):
return "Pass when up > 3"
elif 'Delay of Game' in str(row['desc']):
return "Delay of Game"
else:
return "Other"
scenarios['scenario'] = scenarios.apply(categorize_scenario, axis=1)
scenarios = scenarios.query("scenario != 'Other'")
# Calculate WPA by scenario
scenario_summary = (scenarios
.groupby('scenario')
.agg(
occurrences=('wpa', 'count'),
avg_wpa=('wpa', 'mean'),
se_wpa=('wpa', lambda x: x.std() / np.sqrt(len(x)))
)
.reset_index()
.sort_values('avg_wpa')
)
# Visualize
fig, ax = plt.subplots(figsize=(12, 8))
colors = ['#8B0000' if x < 0 else '#006400' for x in scenario_summary['avg_wpa']]
bars = ax.barh(scenario_summary['scenario'], scenario_summary['avg_wpa'], color=colors)
ax.errorbar(scenario_summary['avg_wpa'], scenario_summary['scenario'],
xerr=scenario_summary['se_wpa'], fmt='none', color='black',
capsize=5, capthick=2)
ax.axvline(x=0, color='black', linestyle='--', alpha=0.7)
ax.set_xlabel('Average WPA', fontsize=12, fontweight='bold')
ax.set_ylabel('Scenario', fontsize=12, fontweight='bold')
ax.set_title('Win Probability Impact of Clock Management Decisions\nFinal 2 minutes of close games (2020-2023)',
fontsize=14, fontweight='bold', pad=20)
plt.text(0.98, 0.02, 'Data: nfl_data_py | Error bars show standard error',
transform=ax.transAxes, ha='right', fontsize=8, style='italic')
plt.tight_layout()
plt.show()
Key Insights from Coach Evaluation
1. **Delay of Game penalties are extremely costly** in late-game situations, averaging significant negative WPA 2. **Passing when leading by more than 3** in the final 2 minutes often backfires, risking clock stoppages and turnovers 3. **Timeout usage when trailing** generally has positive WPA, suggesting teams should be more aggressive 4. **Teams vary widely** in clock management quality, with some consistently making better decisionsSummary
Clock management is a critical yet often misunderstood aspect of football strategy. In this chapter, we explored:
- Time value framework: How to quantify the value of time using win probability derivatives
- Optimal timeout usage: When timeouts are most valuable and common mistakes
- Two-minute drill strategy: Success rates and optimal play selection
- End-of-half decisions: Expected points analysis and risk-reward tradeoffs
- End-of-game scenarios: Victory formation, prevent defense, and strategic considerations
- Hurry-up offense: Efficiency comparison and strategic implications
- Clock-stopping strategies: Mechanisms and optimal usage
- Coach evaluation: How to measure and compare clock management quality
Key takeaways:
- Time value varies dramatically by game situation
- Timeouts are often wasted or used suboptimally
- Two-minute drills require balancing efficiency with clock conservation
- Many traditional approaches (e.g., prevent defense) are suboptimal
- Clock management mistakes can cost multiple percentage points of win probability
- Coaches vary significantly in clock management quality
Exercises
Conceptual Questions
-
Time Value Paradox: Explain why a team trailing late in a game might benefit from letting the clock run on first down rather than stopping it immediately.
-
Prevent Defense: Why does prevent defense often allow higher EPA per play than standard defense? When, if ever, is it appropriate?
-
Timeout Strategy: A team is trailing by 3 points with 1:45 remaining and the opponent has the ball at midfield on 2nd-and-5. Should they use a timeout? Why or why not?
Coding Exercises
Exercise 1: Timeout Value Calculator
Create a function that estimates timeout value based on: - Time remaining - Score differential - Timeouts remaining - Field position Use win probability data to calculate the marginal value of each timeout. **Deliverable**: Function that returns estimated WP increase from having an additional timeout.Exercise 2: Two-Minute Drill Success Prediction
Build a model to predict two-minute drill success based on: - Starting field position - Time available - Timeouts remaining - Score differential Calculate success probability and expected points for various scenarios. **Deliverable**: Heatmap showing success probability across different starting situations.Exercise 3: Coach Clock Management Report Card
For a specific team/season: 1. Identify all plays in final 2 minutes of halves 2. Calculate WPA on each play 3. Identify plays with large negative WPA (likely mistakes) 4. Categorize mistakes by type 5. Create a report card grading clock management **Deliverable**: Summary report with grade (A-F) and specific examples of good/bad decisions.Exercise 4: End-of-Half Decision Model
Create a decision model for end-of-half situations: - Input: Time, field position, timeouts, score - Output: Recommendation (go for it vs take knee) with expected value Compare model recommendations to actual coach decisions. **Deliverable**: Analysis showing how often coaches deviate from optimal strategy and the cost.Further Reading
- Burke, B. (2016). "The Value of a Timeout." ESPN Analytics.
- Lopez, M. (2019). "Bigger data, better questions, and a return to fourth down behavior." Sloan Sports Analytics Conference.
- Romer, D. (2006). "Do firms maximize? Evidence from professional football." Journal of Political Economy, 114(2), 340-365.
- Moskowitz, T. & Wertheim, L. (2011). Scorecasting: The Hidden Influences Behind How Sports Are Played and Games Are Won. Crown Archetype.
- Burke, B. (2020). "Clock Management and Win Probability." Advanced Football Analytics.
References
:::