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

  1. Model Uncertainty: How confident should we be in historical conversion rates? What factors might make this specific situation different?

  2. Risk Tolerance: When might a coach rationally choose a lower-EV option? (Job security, momentum, player confidence)

  3. Sample Size: The 54-yard FG sample might be small. How does this affect our confidence?

  4. Dynamic Decisions: How would this analysis change with 2 minutes left? With a 14-point lead?

  5. 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.