Case Study: Should They Have Gone For It?
"In God we trust; all others must bring data." — W. Edwards Deming
Executive Summary
Fourth-down decision-making is the most visible application of statistical analysis in football. This case study walks through a rigorous expected value analysis of a real fourth-down decision, applying the statistical concepts from Chapter 5.
Skills Applied: - Expected value calculation - Probability estimation - Hypothesis testing - Decision analysis under uncertainty - Communicating statistical results
The Scenario
Situation: Fourth-and-3 at the opponent's 37-yard line Score: Tied at 17 Time: 8:42 remaining in the fourth quarter Weather: Indoor stadium Team: You are advising the head coach
The coach has three options: 1. Go for it - Attempt to convert the fourth down 2. Punt - Give the ball to the opponent deep 3. Field goal - Attempt a 54-yard field goal
Which option maximizes expected points?
Part 1: Gathering the Data
Step 1.1: Historical Conversion Rates
import pandas as pd
import numpy as np
import nfl_data_py as nfl
from scipy import stats
# Load play-by-play data
pbp = nfl.import_pbp_data([2021, 2022, 2023])
def get_conversion_rate(pbp, yards_to_go, tolerance=1):
"""
Calculate fourth-down conversion rate for similar situations.
Parameters
----------
pbp : pd.DataFrame
Play-by-play data
yards_to_go : int
Yards needed for first down
tolerance : int
Range around yards_to_go to include
Returns
-------
dict
Conversion rate and sample size
"""
fourth_downs = pbp.query(
f"down == 4 and "
f"{yards_to_go - tolerance} <= ydstogo <= {yards_to_go + tolerance} and "
"play_type.isin(['pass', 'run'])"
)
conversions = fourth_downs['first_down'].sum()
attempts = len(fourth_downs)
rate = conversions / attempts if attempts > 0 else 0
# Calculate confidence interval
if attempts > 0:
se = np.sqrt(rate * (1 - rate) / attempts)
ci_low = max(0, rate - 1.96 * se)
ci_high = min(1, rate + 1.96 * se)
else:
ci_low, ci_high = 0, 1
return {
'rate': rate,
'conversions': conversions,
'attempts': attempts,
'ci_95': (ci_low, ci_high)
}
# Get conversion rate for 4th and 3
conversion_data = get_conversion_rate(pbp, 3)
print(f"Fourth-and-3 Conversion Rate:")
print(f" Rate: {conversion_data['rate']:.1%}")
print(f" Sample: {conversion_data['conversions']}/{conversion_data['attempts']}")
print(f" 95% CI: ({conversion_data['ci_95'][0]:.1%}, {conversion_data['ci_95'][1]:.1%})")
Step 1.2: Expected Points by Outcome
def get_expected_points(pbp, yardline_100, first_down=True):
"""
Calculate expected points for a field position.
Parameters
----------
pbp : pd.DataFrame
Play-by-play data
yardline_100 : int
Yards from opponent's goal
first_down : bool
Whether it's a first down situation
Returns
-------
dict
Expected points and sample info
"""
if first_down:
situations = pbp.query(
f"down == 1 and ydstogo == 10 and "
f"{yardline_100 - 5} <= yardline_100 <= {yardline_100 + 5}"
)
else:
situations = pbp.query(
f"{yardline_100 - 5} <= yardline_100 <= {yardline_100 + 5}"
)
ep = situations['ep'].mean() if len(situations) > 0 else 0
n = len(situations)
return {'ep': ep, 'n': n}
# Expected points if we convert (1st and 10 at ~34)
ep_convert = get_expected_points(pbp, 34)
print(f"EP if convert: {ep_convert['ep']:.2f} (n={ep_convert['n']})")
# Expected points if we fail (opponent gets ball at 37)
# Need to negate and adjust for opponent's perspective
ep_fail = get_expected_points(pbp, 63) # 100-37
print(f"Opponent's EP if we fail: {ep_fail['ep']:.2f}")
print(f"Our EP if we fail: {-ep_fail['ep']:.2f}")
Step 1.3: Field Goal and Punt Data
def get_fg_success_rate(pbp, distance):
"""Get field goal success rate by distance."""
fgs = pbp.query(
f"play_type == 'field_goal' and "
f"{distance - 3} <= kick_distance <= {distance + 3}"
)
makes = fgs['field_goal_result'].eq('made').sum()
attempts = len(fgs)
rate = makes / attempts if attempts > 0 else 0
return {'rate': rate, 'makes': makes, 'attempts': attempts}
def get_punt_expected_position(pbp, yardline):
"""Get expected opponent field position after punt."""
punts = pbp.query(
f"play_type == 'punt' and "
f"{yardline - 10} <= yardline_100 <= {yardline + 10}"
)
# Net punt yards
avg_net = punts['kick_distance'].mean() - punts['return_yards'].fillna(0).mean()
# Opponent's field position
opponent_yardline = 100 - (yardline - avg_net)
return {'avg_net': avg_net, 'opponent_yardline': opponent_yardline, 'n': len(punts)}
# 54-yard field goal (37 + 17 for line of scrimmage)
fg_data = get_fg_success_rate(pbp, 54)
print(f"54-yard FG success rate: {fg_data['rate']:.1%} ({fg_data['makes']}/{fg_data['attempts']})")
# Punt from 37
punt_data = get_punt_expected_position(pbp, 37)
print(f"Average punt net: {punt_data['avg_net']:.1f} yards")
print(f"Opponent expected start: ~{punt_data['opponent_yardline']:.0f} yard line")
Part 2: Expected Value Calculations
Step 2.1: Go For It Analysis
def calculate_go_for_it_ev(conversion_rate, ep_convert, ep_fail):
"""
Calculate expected value of going for it.
EV = P(convert) * EP(convert) + P(fail) * EP(fail)
"""
ev = conversion_rate * ep_convert + (1 - conversion_rate) * ep_fail
return ev
# Using our data
conversion_rate = 0.55 # From historical data
ep_if_convert = 4.0 # EP from 1st and 10 at 34
ep_if_fail = -1.5 # Opponent gets ball at 37
go_for_it_ev = calculate_go_for_it_ev(conversion_rate, ep_if_convert, ep_if_fail)
print(f"Go For It Expected Value: {go_for_it_ev:.2f} points")
Step 2.2: Field Goal Analysis
def calculate_fg_ev(success_rate, ep_if_miss):
"""
Calculate expected value of field goal attempt.
EV = P(make) * 3 + P(miss) * EP(miss)
"""
ev = success_rate * 3 + (1 - success_rate) * ep_if_miss
return ev
# 54-yard FG data
fg_success = 0.58 # Historical rate for 54 yards
ep_if_miss = -1.5 # Opponent gets ball at spot (47 if miss, similar to fail)
fg_ev = calculate_fg_ev(fg_success, ep_if_miss)
print(f"Field Goal Expected Value: {fg_ev:.2f} points")
Step 2.3: Punt Analysis
def calculate_punt_ev(opponent_ep):
"""
Calculate expected value of punting.
EV = -Opponent_EP after punt
"""
return -opponent_ep
# After punt, opponent at ~20 yard line
opponent_ep_after_punt = 0.5 # Low EP deep in own territory
punt_ev = calculate_punt_ev(opponent_ep_after_punt)
print(f"Punt Expected Value: {punt_ev:.2f} points")
Step 2.4: Summary Comparison
def create_decision_summary():
"""Create summary of all options."""
decisions = {
'Go For It': {
'ev': 1.53,
'best_case': 'Convert, EP = +4.0',
'worst_case': 'Fail, EP = -1.5',
'probability_weighted': '55% × 4.0 + 45% × (-1.5)'
},
'Field Goal': {
'ev': 1.11,
'best_case': 'Make, EP = +3.0',
'worst_case': 'Miss, EP = -1.5',
'probability_weighted': '58% × 3.0 + 42% × (-1.5)'
},
'Punt': {
'ev': -0.50,
'best_case': 'Pin deep, EP = -0.3',
'worst_case': 'Touchback, EP = -0.7',
'probability_weighted': 'Average outcome'
}
}
print("=" * 60)
print("DECISION SUMMARY: 4th and 3 at opponent's 37")
print("=" * 60)
for decision, data in decisions.items():
print(f"\n{decision}:")
print(f" Expected Value: {data['ev']:.2f} points")
print(f" Best Case: {data['best_case']}")
print(f" Worst Case: {data['worst_case']}")
print("\n" + "=" * 60)
print("RECOMMENDATION: GO FOR IT (+1.53 EV)")
print("=" * 60)
create_decision_summary()
Part 3: Uncertainty Analysis
Step 3.1: Sensitivity Analysis
import matplotlib.pyplot as plt
def sensitivity_analysis():
"""
How does the decision change if our estimates are wrong?
"""
conversion_rates = np.linspace(0.3, 0.8, 50)
go_for_it_evs = [
calculate_go_for_it_ev(rate, 4.0, -1.5)
for rate in conversion_rates
]
fg_ev = 1.11 # Constant
punt_ev = -0.50 # Constant
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(conversion_rates * 100, go_for_it_evs, 'b-', linewidth=2, label='Go For It')
ax.axhline(fg_ev, color='orange', linestyle='--', label=f'Field Goal ({fg_ev:.2f})')
ax.axhline(punt_ev, color='green', linestyle='--', label=f'Punt ({punt_ev:.2f})')
# Find breakeven point
breakeven = (fg_ev + 1.5) / (4.0 + 1.5)
ax.axvline(breakeven * 100, color='gray', linestyle=':', alpha=0.7)
ax.text(breakeven * 100 + 1, 2, f'Breakeven: {breakeven:.0%}', fontsize=10)
ax.set_xlabel('Conversion Rate (%)')
ax.set_ylabel('Expected Value (Points)')
ax.set_title('Decision Sensitivity to Conversion Rate')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('sensitivity_analysis.png', dpi=150)
return breakeven
breakeven = sensitivity_analysis()
print(f"\nBreakeven conversion rate: {breakeven:.1%}")
print("If conversion rate > breakeven, go for it beats FG")
Step 3.2: Confidence Interval Impact
def analyze_uncertainty():
"""
Account for uncertainty in our conversion rate estimate.
"""
# Our point estimate and CI
rate_estimate = 0.55
ci_low = 0.48
ci_high = 0.62
scenarios = {
'Point Estimate': rate_estimate,
'95% CI Lower': ci_low,
'95% CI Upper': ci_high
}
print("Decision Robustness Analysis:")
print("-" * 50)
for scenario, rate in scenarios.items():
ev = calculate_go_for_it_ev(rate, 4.0, -1.5)
decision = "Go For It" if ev > 1.11 else "Field Goal"
print(f"{scenario} ({rate:.0%}): EV = {ev:.2f} → {decision}")
print("-" * 50)
print("Conclusion: Go For It is optimal across entire 95% CI")
analyze_uncertainty()
Part 4: Context Adjustments
Step 4.1: Team-Specific Factors
def adjust_for_team_context(
base_conversion_rate,
offensive_epa_rank, # 1-32
defensive_epa_rank, # 1-32 (of opponent)
qb_clutch_factor # Multiplier based on QB performance
):
"""
Adjust conversion rate for team-specific factors.
This is a simplified model - real models would use
more sophisticated adjustments.
"""
# Offensive adjustment: top offenses get boost
off_adj = (17 - offensive_epa_rank) / 100 # +0.16 for rank 1, -0.15 for rank 32
# Defensive adjustment: tough defense reduces rate
def_adj = (defensive_epa_rank - 17) / 100 # +0.15 for rank 32 def, -0.16 for rank 1
# QB clutch adjustment
qb_adj = (qb_clutch_factor - 1) * 0.1
adjusted_rate = base_conversion_rate + off_adj + def_adj + qb_adj
return max(0.2, min(0.9, adjusted_rate)) # Bound between 20% and 90%
# Example: Good offense (rank 8) vs average defense (rank 16), clutch QB (1.1)
adjusted = adjust_for_team_context(0.55, 8, 16, 1.1)
print(f"Base conversion rate: 55%")
print(f"Adjusted for team context: {adjusted:.0%}")
Step 4.2: Game Situation Factors
def game_situation_adjustment(
base_ev,
score_differential,
time_remaining_seconds,
timeouts_remaining
):
"""
Adjust decision based on game situation.
Key insight: In close games with time, risk is acceptable.
When protecting a lead, conservatism has value.
"""
# Time pressure adjustment
time_minutes = time_remaining_seconds / 60
if score_differential == 0:
# Tied game - maximize expected points
situation_note = "Tied game: Maximize EV"
adjustment = 0
elif score_differential > 0 and score_differential <= 7:
# Small lead - slightly conservative
situation_note = "Small lead: Balance EV with risk"
adjustment = -0.2
elif score_differential > 7:
# Big lead - run clock, be conservative
situation_note = "Big lead: Conservative approach"
adjustment = -0.5
elif score_differential < 0 and score_differential >= -7:
# Small deficit - need points
situation_note = "Small deficit: Be aggressive"
adjustment = 0.2
else:
# Big deficit - must take risks
situation_note = "Big deficit: Maximum aggression"
adjustment = 0.5
return base_ev + adjustment, situation_note
# Our situation: Tied, 8:42 remaining
adjusted_ev, note = game_situation_adjustment(1.53, 0, 522, 3)
print(f"Base EV: 1.53")
print(f"Situation: {note}")
print(f"Adjusted EV: {adjusted_ev:.2f}")
Part 5: Communicating the Recommendation
Step 5.1: Executive Summary
def generate_recommendation_report():
"""Generate a coach-friendly recommendation."""
report = """
FOURTH DOWN DECISION ANALYSIS
=============================
SITUATION: 4th and 3, Opponent's 37, Tied, 8:42 4Q
RECOMMENDATION: GO FOR IT
KEY NUMBERS:
- Historical 4th-and-3 conversion rate: 55%
- Expected Points if you GO: +1.5 points
- Expected Points if you KICK FG: +1.1 points
- Expected Points if you PUNT: -0.5 points
WHY GO FOR IT:
1. You convert more often than not (55%)
2. Even if you fail, they're at their 37 (not great for them)
3. Plenty of time to recover if you fail
4. FG success only 58% from 54 yards
RISK ACKNOWLEDGMENT:
- If you fail, opponent has short field
- Public/media may criticize aggressive call
- You need your offense to execute
BOTTOM LINE:
The expected point advantage of going for it (+0.4 vs FG)
compounds over a season to significant wins. Trust the math.
"""
print(report)
generate_recommendation_report()
Step 5.2: Visual Decision Aid
def create_decision_visual():
"""Create visual comparison of options."""
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Left: EV comparison
options = ['Go For It', 'Field Goal', 'Punt']
evs = [1.53, 1.11, -0.50]
colors = ['green', 'orange', 'red']
axes[0].barh(options, evs, color=colors, edgecolor='black')
axes[0].axvline(0, color='black', linewidth=0.5)
axes[0].set_xlabel('Expected Value (Points)')
axes[0].set_title('Expected Value by Decision')
for i, (opt, ev) in enumerate(zip(options, evs)):
axes[0].text(ev + 0.05, i, f'{ev:+.2f}', va='center')
# Right: Outcome tree for "Go For It"
axes[1].text(0.1, 0.9, "GO FOR IT", fontsize=14, fontweight='bold')
axes[1].text(0.1, 0.7, "├─ 55%: Convert", fontsize=12)
axes[1].text(0.15, 0.6, "│ 1st & 10 at 34", fontsize=10)
axes[1].text(0.15, 0.5, "│ EP = +4.0", fontsize=10, color='green')
axes[1].text(0.1, 0.35, "└─ 45%: Fail", fontsize=12)
axes[1].text(0.15, 0.25, " Opp ball at 37", fontsize=10)
axes[1].text(0.15, 0.15, " EP = -1.5", fontsize=10, color='red')
axes[1].set_xlim(0, 1)
axes[1].set_ylim(0, 1)
axes[1].axis('off')
axes[1].set_title('Outcome Tree')
plt.tight_layout()
plt.savefig('decision_visual.png', dpi=150, bbox_inches='tight')
create_decision_visual()
Discussion Questions
-
Model Uncertainty: How confident should we be in historical conversion rates? What factors might make this specific situation different?
-
Risk Tolerance: When might a coach rationally choose a lower-EV option? (Job security, momentum, player confidence)
-
Sample Size: The 54-yard FG sample might be small. How does this affect our confidence?
-
Dynamic Decisions: How would this analysis change with 2 minutes left? With a 14-point lead?
-
Implementation: How would you present this analysis to a coach in real-time during a game?
Extension: Build Your Own Decision Model
Create a function that takes any fourth-down situation and returns a recommendation:
def fourth_down_decision(
yards_to_go: int,
yardline_100: int,
score_differential: int,
seconds_remaining: int,
timeouts: int
) -> dict:
"""
Analyze a fourth-down decision.
Returns
-------
dict
Recommendation with EV for each option
"""
# Your implementation here
pass
Test your model against known decisions and see how it performs.