14 min read

Every basketball game tells a story through numbers. From the opening tip to the final buzzer, the probability of each team winning fluctuates with every possession, every shot, and every turnover. Win probability models capture this narrative...

Chapter 21: In-Game Win Probability

Introduction

Every basketball game tells a story through numbers. From the opening tip to the final buzzer, the probability of each team winning fluctuates with every possession, every shot, and every turnover. Win probability models capture this narrative mathematically, providing a real-time assessment of each team's chances based on the current game state.

Win probability has transformed how we understand basketball games. What once required expert intuition can now be quantified: Was that three-pointer truly a "dagger"? How improbable was that comeback? Which plays actually swung the game's outcome? These questions, and many more, can be answered through rigorous win probability modeling.

This chapter provides a comprehensive treatment of in-game win probability for basketball, from the foundational mathematics to practical implementation. We will build models from scratch, evaluate their performance, and explore their numerous applications in player evaluation, broadcast analytics, and strategic decision-making.

21.1 Foundations of Win Probability

21.1.1 Defining Win Probability

Win probability represents the likelihood that a team will win the game given the current game state. Formally, for team A at time t:

$$WP_A(t) = P(\text{Team A wins} | \text{Game State at time } t)$$

The game state encompasses all relevant information available at time t, typically including:

  • Score differential: The current point margin
  • Time remaining: Seconds left in the game
  • Possession: Which team has the ball (or neutral state)
  • Team strength: Pre-game expectations about team quality
  • Home court: Whether the team is playing at home

The complementary probability for team B is simply:

$$WP_B(t) = 1 - WP_A(t)$$

21.1.2 Historical Context

Win probability models in sports originated with baseball analytics in the early 2000s, pioneered by researchers like Tom Tango and organizations like Baseball Prospectus. Basketball adopted these methods shortly after, with early work appearing on sites like Basketball-Reference and in academic research.

The NBA's official win probability model, introduced in the 2010s, brought these concepts to mainstream audiences. Today, win probability graphics appear in virtually every NBA broadcast, helping casual fans understand game dynamics and providing fodder for endless debates about "clutch" performance.

21.1.3 Why Win Probability Matters

Win probability serves multiple purposes in basketball analytics:

  1. Narrative quantification: Transforming subjective assessments ("that was a huge shot") into objective measurements
  2. Player evaluation: Measuring contributions in context through Win Probability Added (WPA)
  3. Strategy analysis: Identifying optimal decisions in various game situations
  4. Fan engagement: Enhancing broadcast coverage with real-time probability updates
  5. Historical comparison: Ranking games, comebacks, and clutch performances across eras

21.2 Building Win Probability Models from Historical Data

21.2.1 Data Requirements

Building an accurate win probability model requires extensive historical play-by-play data. Ideally, this data should include:

  • Game identification: Unique identifiers for each game
  • Event timing: Precise timestamps for each play (period, time remaining)
  • Score tracking: Running score for both teams
  • Possession indicators: Which team has possession after each event
  • Event outcomes: Points scored, turnovers, fouls, etc.
  • Team information: Home/away designation, team identifiers

The NBA's play-by-play data, available through various APIs and data providers, contains all of these elements. For robust model training, we recommend using at least five seasons of data, encompassing approximately 6,000+ regular season games.

21.2.2 Data Preprocessing

Before model training, the raw play-by-play data must be transformed into a structured format suitable for analysis. The key steps include:

1. Calculating Time Remaining

Convert period and clock time into total seconds remaining:

def calculate_seconds_remaining(period, clock_minutes, clock_seconds):
    """
    Calculate total seconds remaining in the game.

    Parameters:
    -----------
    period : int
        Current period (1-4 for regulation, 5+ for overtime)
    clock_minutes : int
        Minutes showing on game clock
    clock_seconds : int
        Seconds showing on game clock

    Returns:
    --------
    float
        Total seconds remaining in the game
    """
    if period <= 4:
        # Regulation: 12-minute quarters
        periods_remaining = 4 - period
        seconds_in_current_period = clock_minutes * 60 + clock_seconds
        return periods_remaining * 720 + seconds_in_current_period
    else:
        # Overtime: 5-minute periods
        # We treat overtime specially, as the game could extend indefinitely
        seconds_in_current_ot = clock_minutes * 60 + clock_seconds
        return seconds_in_current_ot

2. Determining Possession

Possession must be inferred from play-by-play events. After a made basket, possession goes to the opposing team. After a defensive rebound, possession goes to the rebounding team. The logic can be complex due to technical fouls, jump balls, and other special situations.

3. Calculating Score Differential

For modeling purposes, we typically express the score differential from the perspective of the team with possession (or the home team if possession is neutral):

def calculate_score_differential(home_score, away_score, possession_team='home'):
    """
    Calculate score differential from perspective of specified team.
    """
    if possession_team == 'home':
        return home_score - away_score
    else:
        return away_score - home_score

21.2.3 Creating Training Labels

The target variable for our model is binary: did the team ultimately win the game? For each game state observation, we label it with the final outcome:

def create_training_data(play_by_play_df, game_results_df):
    """
    Create training dataset with game state features and win labels.
    """
    # Merge game outcomes
    training_data = play_by_play_df.merge(
        game_results_df[['game_id', 'home_win']],
        on='game_id'
    )

    # Create win label from perspective of team with possession
    training_data['team_won'] = training_data.apply(
        lambda row: row['home_win'] if row['possession'] == 'home'
                    else not row['home_win'],
        axis=1
    )

    return training_data

21.2.4 Sample Size Considerations

Win probability models face a unique challenge: certain game states are rare. Leading by 30 points with 2 minutes left occurs infrequently, yet we need probability estimates for such situations.

To address this, analysts employ several strategies:

  1. Binning: Group similar game states together (e.g., all situations with 25-35 point leads and 1-3 minutes remaining)
  2. Smoothing: Use statistical techniques to smooth probability estimates across adjacent game states
  3. Parametric models: Assume a functional form (like logistic regression) that can extrapolate to rare situations
  4. Bayesian approaches: Incorporate prior beliefs about extreme situations

21.3 Feature Engineering for Game State

21.3.1 Core Features

The fundamental features for win probability modeling are score differential and time remaining. These two variables alone explain the vast majority of variance in game outcomes.

Score Differential

The most important predictor of winning is, unsurprisingly, being ahead. The relationship between lead size and win probability is nonlinear - each additional point matters less as the lead grows larger.

Time Remaining

Time interacts critically with score differential. A 10-point lead with 10 minutes left is much less secure than a 10-point lead with 1 minute left. We typically transform time remaining to capture this relationship:

import numpy as np

def engineer_time_features(seconds_remaining):
    """
    Engineer features from time remaining.
    """
    features = {
        'seconds_remaining': seconds_remaining,
        'log_seconds': np.log1p(seconds_remaining),
        'sqrt_seconds': np.sqrt(seconds_remaining),
        'minutes_remaining': seconds_remaining / 60
    }
    return features

21.3.2 Possession Value

Possession is worth approximately 1.1 points in expectation (based on league-average offensive efficiency). Including possession as a feature allows the model to account for this advantage:

def create_possession_feature(possession_team, perspective_team):
    """
    Create binary feature for possession advantage.

    Returns 1 if the perspective team has possession, -1 if opponent has it,
    0 if neutral (e.g., between plays).
    """
    if possession_team == perspective_team:
        return 1
    elif possession_team is None:
        return 0
    else:
        return -1

21.3.3 Home Court Advantage

Home teams win approximately 55-60% of NBA games, representing a 3-4 point advantage. This should be incorporated either as a feature or by adjusting the baseline probability:

def home_court_adjustment(is_home, home_advantage_points=3.5):
    """
    Return effective point adjustment for home court advantage.
    """
    if is_home:
        return home_advantage_points / 2
    else:
        return -home_advantage_points / 2

21.3.4 Team Strength Differential

Pre-game expectations matter. A 5-point lead for the Warriors against a rebuilding team differs from a 5-point lead for that rebuilding team against the Warriors. We can incorporate team strength using pre-game point spreads or power ratings:

def incorporate_team_strength(score_margin, team_rating_diff, seconds_remaining,
                              total_game_seconds=2880):
    """
    Adjust effective margin based on team strength differential.

    Parameters:
    -----------
    score_margin : int
        Current score differential
    team_rating_diff : float
        Expected point differential per game (positive = team A favored)
    seconds_remaining : float
        Seconds remaining in game
    total_game_seconds : int
        Total seconds in regulation (2880 for NBA)

    Returns:
    --------
    float
        Adjusted margin accounting for team strength
    """
    # Pro-rate the expected differential based on time remaining
    expected_remaining_diff = team_rating_diff * (seconds_remaining / total_game_seconds)

    return score_margin + expected_remaining_diff

21.3.5 Interaction Terms

The interaction between score differential and time remaining is crucial. A simple linear model misses this relationship. We create interaction terms to capture it:

def create_interaction_features(score_diff, seconds_remaining):
    """
    Create interaction features between score and time.
    """
    features = {
        'score_time_interaction': score_diff * np.sqrt(seconds_remaining),
        'score_per_minute': score_diff / max(seconds_remaining / 60, 0.1),
        'margin_squared_time': (score_diff ** 2) * np.log1p(seconds_remaining)
    }
    return features

21.3.6 Advanced Features

For more sophisticated models, additional features can improve predictions:

Timeout Availability Teams with timeouts remaining have more strategic flexibility in close games.

Foul Situation Teams in the bonus or with players in foul trouble face different dynamics.

Momentum Indicators Recent scoring runs or performance streaks may carry predictive value (though this is debated).

Pace Adjustment Faster-paced games see more scoring variance, affecting probability estimates.

21.4 Logistic Regression for Win Probability

21.4.1 Why Logistic Regression?

Logistic regression is the workhorse of win probability modeling for several reasons:

  1. Probability output: Directly produces probabilities between 0 and 1
  2. Interpretability: Coefficients have clear meanings
  3. Efficiency: Fast to train and apply in real-time
  4. Robustness: Performs well even with limited data for extreme situations
  5. Calibration: Naturally well-calibrated when properly specified

The logistic regression model takes the form:

$$WP = \frac{1}{1 + e^{-(\beta_0 + \beta_1 x_1 + \beta_2 x_2 + ... + \beta_n x_n)}}$$

Where $x_i$ are our features and $\beta_i$ are the learned coefficients.

21.4.2 Model Specification

A standard win probability model specification includes:

from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
import pandas as pd
import numpy as np

def build_win_probability_model(training_data):
    """
    Build and train a logistic regression win probability model.

    Parameters:
    -----------
    training_data : DataFrame
        Must contain: score_diff, seconds_remaining, has_possession,
                      team_rating_diff, target (binary win indicator)

    Returns:
    --------
    tuple
        (trained_model, scaler, feature_columns)
    """
    # Feature engineering
    training_data = training_data.copy()
    training_data['log_seconds'] = np.log1p(training_data['seconds_remaining'])
    training_data['sqrt_seconds'] = np.sqrt(training_data['seconds_remaining'])
    training_data['score_time_interact'] = (
        training_data['score_diff'] * training_data['sqrt_seconds']
    )
    training_data['score_log_time'] = (
        training_data['score_diff'] * training_data['log_seconds']
    )

    # Define feature columns
    feature_columns = [
        'score_diff',
        'log_seconds',
        'sqrt_seconds',
        'has_possession',
        'team_rating_diff',
        'score_time_interact',
        'score_log_time'
    ]

    X = training_data[feature_columns]
    y = training_data['target']

    # Scale features
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    # Train model
    model = LogisticRegression(
        penalty='l2',
        C=1.0,
        solver='lbfgs',
        max_iter=1000,
        random_state=42
    )
    model.fit(X_scaled, y)

    return model, scaler, feature_columns

21.4.3 Handling Non-Linearity

Pure logistic regression assumes a linear relationship between features and log-odds. We can capture non-linearity through:

Polynomial Features

def add_polynomial_features(X, degree=2):
    """Add polynomial and interaction terms."""
    poly = PolynomialFeatures(degree=degree, include_bias=False)
    return poly.fit_transform(X)

Spline Transformations

from sklearn.preprocessing import SplineTransformer

def add_spline_features(X, n_knots=5):
    """Add spline basis functions for smooth non-linearity."""
    spline = SplineTransformer(n_knots=n_knots, degree=3)
    return spline.fit_transform(X)

Binned Features

def create_time_bins(seconds_remaining, bins=[0, 60, 180, 360, 720, 1440, 2880]):
    """Create indicator variables for time remaining bins."""
    return pd.cut(seconds_remaining, bins=bins, labels=False)

21.4.4 Alternative Modeling Approaches

While logistic regression is standard, other approaches offer advantages:

Gradient Boosted Trees (XGBoost, LightGBM) - Automatically capture interactions and non-linearity - Often achieve better predictive accuracy - Require careful calibration to produce true probabilities

Neural Networks - Can model arbitrarily complex relationships - Require large datasets and careful tuning - May overfit to training data quirks

Bayesian Logistic Regression - Provides uncertainty estimates for predictions - Naturally handles rare game states through priors - Computationally more intensive

For most applications, well-specified logistic regression remains the recommended approach due to its balance of accuracy, interpretability, and computational efficiency.

21.5 Model Calibration and Evaluation

21.5.1 What is Calibration?

A well-calibrated probability model means that when it predicts 70% win probability, the team actually wins approximately 70% of such games. Calibration is distinct from discrimination (separating winners from losers) - a model can discriminate well but be poorly calibrated.

Mathematically, a model is calibrated if:

$$E[\text{Outcome} | \hat{p}] = \hat{p}$$

Where $\hat{p}$ is the predicted probability.

21.5.2 Assessing Calibration

Calibration Plots

The primary tool for assessing calibration is the calibration plot (reliability diagram). We bin predictions by probability range and compare mean predicted probability to actual win rate:

import matplotlib.pyplot as plt
from sklearn.calibration import calibration_curve

def plot_calibration(y_true, y_pred_proba, n_bins=10):
    """
    Create calibration plot comparing predicted vs actual probabilities.
    """
    fraction_positive, mean_predicted = calibration_curve(
        y_true, y_pred_proba, n_bins=n_bins, strategy='uniform'
    )

    fig, ax = plt.subplots(figsize=(8, 8))

    # Perfect calibration line
    ax.plot([0, 1], [0, 1], 'k--', label='Perfect Calibration')

    # Model calibration
    ax.plot(mean_predicted, fraction_positive, 's-', label='Model')

    ax.set_xlabel('Mean Predicted Probability')
    ax.set_ylabel('Fraction of Positives (Actual Win Rate)')
    ax.set_title('Calibration Plot')
    ax.legend()
    ax.grid(True, alpha=0.3)

    return fig, ax

Brier Score

The Brier score measures both calibration and discrimination:

$$BS = \frac{1}{N} \sum_{i=1}^{N} (p_i - o_i)^2$$

Where $p_i$ is predicted probability and $o_i$ is the outcome (0 or 1). Lower is better, with 0 being perfect.

from sklearn.metrics import brier_score_loss

def evaluate_brier_score(y_true, y_pred_proba):
    """Calculate and interpret Brier score."""
    brier = brier_score_loss(y_true, y_pred_proba)

    # Baseline: predicting 50% for everything
    baseline_brier = brier_score_loss(y_true, [0.5] * len(y_true))

    # Skill score
    skill = 1 - (brier / baseline_brier)

    return {
        'brier_score': brier,
        'baseline_brier': baseline_brier,
        'brier_skill_score': skill
    }

21.5.3 Calibration by Game Situation

Win probability models should be evaluated across different game situations:

def evaluate_by_situation(y_true, y_pred_proba, seconds_remaining, score_diff):
    """
    Evaluate model performance across different game situations.
    """
    results = {}

    # By time remaining
    time_bins = [
        ('Final 2 min', seconds_remaining <= 120),
        ('2-5 min', (seconds_remaining > 120) & (seconds_remaining <= 300)),
        ('5-12 min', (seconds_remaining > 300) & (seconds_remaining <= 720)),
        ('Q1-Q3', seconds_remaining > 720)
    ]

    for name, mask in time_bins:
        if mask.sum() > 0:
            results[name] = brier_score_loss(y_true[mask], y_pred_proba[mask])

    # By score differential
    margin_bins = [
        ('Close (0-5)', abs(score_diff) <= 5),
        ('Moderate (6-15)', (abs(score_diff) > 5) & (abs(score_diff) <= 15)),
        ('Large (16+)', abs(score_diff) > 15)
    ]

    for name, mask in margin_bins:
        if mask.sum() > 0:
            results[name] = brier_score_loss(y_true[mask], y_pred_proba[mask])

    return results

21.5.4 Recalibration Techniques

If a model is poorly calibrated, several techniques can improve it:

Platt Scaling Fit a logistic regression on the predictions to recalibrate:

from sklearn.calibration import CalibratedClassifierCV

def platt_calibration(model, X_calib, y_calib):
    """Apply Platt scaling for calibration."""
    calibrated = CalibratedClassifierCV(model, method='sigmoid', cv='prefit')
    calibrated.fit(X_calib, y_calib)
    return calibrated

Isotonic Regression A non-parametric approach that preserves ranking:

def isotonic_calibration(model, X_calib, y_calib):
    """Apply isotonic regression for calibration."""
    calibrated = CalibratedClassifierCV(model, method='isotonic', cv='prefit')
    calibrated.fit(X_calib, y_calib)
    return calibrated

21.5.5 Cross-Validation Strategy

For robust evaluation, use time-based cross-validation to prevent data leakage:

from sklearn.model_selection import TimeSeriesSplit

def temporal_cross_validation(data, model_builder, n_splits=5):
    """
    Perform time-series cross-validation for win probability model.
    """
    # Sort by date
    data = data.sort_values('game_date')

    tscv = TimeSeriesSplit(n_splits=n_splits)
    results = []

    for train_idx, test_idx in tscv.split(data):
        train_data = data.iloc[train_idx]
        test_data = data.iloc[test_idx]

        # Train model
        model, scaler, features = model_builder(train_data)

        # Predict
        X_test = scaler.transform(test_data[features])
        predictions = model.predict_proba(X_test)[:, 1]

        # Evaluate
        brier = brier_score_loss(test_data['target'], predictions)
        results.append(brier)

    return np.mean(results), np.std(results)

21.6 Real-Time Win Probability Applications

21.6.1 Live Prediction Pipeline

Deploying win probability in real-time requires an efficient prediction pipeline:

class RealTimeWinProbability:
    """
    Real-time win probability prediction system.
    """

    def __init__(self, model, scaler, feature_columns):
        self.model = model
        self.scaler = scaler
        self.feature_columns = feature_columns
        self.game_history = []

    def predict(self, game_state):
        """
        Generate win probability from current game state.

        Parameters:
        -----------
        game_state : dict
            Must contain: score_diff, seconds_remaining, has_possession,
                          team_rating_diff

        Returns:
        --------
        float
            Win probability for the reference team
        """
        # Engineer features
        features = self._engineer_features(game_state)

        # Create feature vector
        X = np.array([[features[col] for col in self.feature_columns]])

        # Scale and predict
        X_scaled = self.scaler.transform(X)
        win_prob = self.model.predict_proba(X_scaled)[0, 1]

        # Store for history
        self.game_history.append({
            'timestamp': game_state.get('timestamp'),
            'seconds_remaining': game_state['seconds_remaining'],
            'win_probability': win_prob,
            'score_diff': game_state['score_diff']
        })

        return win_prob

    def _engineer_features(self, game_state):
        """Engineer features from raw game state."""
        features = game_state.copy()
        features['log_seconds'] = np.log1p(game_state['seconds_remaining'])
        features['sqrt_seconds'] = np.sqrt(game_state['seconds_remaining'])
        features['score_time_interact'] = (
            game_state['score_diff'] * features['sqrt_seconds']
        )
        features['score_log_time'] = (
            game_state['score_diff'] * features['log_seconds']
        )
        return features

    def get_probability_history(self):
        """Return win probability history for the current game."""
        return pd.DataFrame(self.game_history)

21.6.2 Visualization for Broadcasts

Win probability graphs have become a staple of sports broadcasts. Key design principles include:

def create_broadcast_wp_graph(wp_history, home_team, away_team):
    """
    Create broadcast-quality win probability graph.
    """
    fig, ax = plt.subplots(figsize=(12, 6))

    # Convert seconds remaining to game time elapsed
    max_seconds = 2880  # Regulation
    game_time = max_seconds - wp_history['seconds_remaining']

    # Plot win probability
    ax.fill_between(game_time, 0.5, wp_history['win_probability'],
                    where=wp_history['win_probability'] >= 0.5,
                    color='#1f77b4', alpha=0.7, label=home_team)
    ax.fill_between(game_time, 0.5, wp_history['win_probability'],
                    where=wp_history['win_probability'] < 0.5,
                    color='#ff7f0e', alpha=0.7, label=away_team)

    # Reference line at 50%
    ax.axhline(y=0.5, color='black', linestyle='-', linewidth=1)

    # Quarter markers
    for q in [720, 1440, 2160]:
        ax.axvline(x=q, color='gray', linestyle='--', alpha=0.5)

    # Formatting
    ax.set_xlim(0, max_seconds)
    ax.set_ylim(0, 1)
    ax.set_xlabel('Game Time (seconds)')
    ax.set_ylabel('Win Probability')
    ax.set_title('Win Probability')
    ax.legend(loc='upper right')

    # Y-axis as percentage
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0%}'))

    # X-axis as quarter labels
    ax.set_xticks([0, 720, 1440, 2160, 2880])
    ax.set_xticklabels(['Start', 'Q2', 'Q3', 'Q4', 'End'])

    return fig, ax

21.6.3 Mobile and Web Applications

Modern win probability applications often include:

  1. Push notifications for major probability swings
  2. Comparison overlays showing multiple games simultaneously
  3. Historical context ("This is the largest comeback in franchise history")
  4. Interactive features allowing users to simulate scenarios

21.7 Win Probability Added (WPA) for Player Evaluation

21.7.1 Defining WPA

Win Probability Added measures a player's contribution by tracking the change in win probability resulting from their plays:

$$WPA = \sum_{i \in \text{player's plays}} (WP_{\text{after}} - WP_{\text{before}})_i$$

A player who consistently makes plays that increase their team's win probability will have positive WPA, regardless of when those plays occur.

21.7.2 Calculating WPA

def calculate_player_wpa(play_by_play, win_prob_model):
    """
    Calculate WPA for each player in a game.

    Parameters:
    -----------
    play_by_play : DataFrame
        Play-by-play data with player attributions
    win_prob_model : RealTimeWinProbability
        Trained win probability model

    Returns:
    --------
    DataFrame
        Player WPA summary
    """
    # Calculate win probability at each play
    play_by_play = play_by_play.copy()
    play_by_play['win_prob'] = play_by_play.apply(
        lambda row: win_prob_model.predict({
            'score_diff': row['home_score'] - row['away_score'],
            'seconds_remaining': row['seconds_remaining'],
            'has_possession': 1 if row['possession'] == 'home' else -1,
            'team_rating_diff': row.get('team_rating_diff', 0)
        }),
        axis=1
    )

    # Calculate WPA for each play
    play_by_play['wpa'] = play_by_play['win_prob'].diff()

    # Handle first play of game
    play_by_play.loc[play_by_play.index[0], 'wpa'] = (
        play_by_play.loc[play_by_play.index[0], 'win_prob'] - 0.5
    )

    # Attribute to players
    # Positive plays (scores, assists, steals, blocks)
    positive_wpa = play_by_play[play_by_play['wpa'] > 0].groupby('player_id')['wpa'].sum()

    # Negative plays (turnovers, missed shots, fouls)
    negative_wpa = play_by_play[play_by_play['wpa'] < 0].groupby('player_id')['wpa'].sum()

    # Combine
    player_wpa = pd.DataFrame({
        'positive_wpa': positive_wpa,
        'negative_wpa': negative_wpa
    }).fillna(0)
    player_wpa['total_wpa'] = player_wpa['positive_wpa'] + player_wpa['negative_wpa']

    return player_wpa.sort_values('total_wpa', ascending=False)

21.7.3 Interpreting WPA

WPA has several important properties:

  1. Zero-sum within a game: Total WPA for one team equals the negative of the other team's total WPA
  2. Context-dependent: The same play (e.g., a made three-pointer) can have vastly different WPA values depending on game situation
  3. Not predictive: WPA measures what happened, not player skill; high-WPA players aren't necessarily better
  4. Affected by opportunity: Players in close games have more WPA opportunity

21.7.4 WPA Leaders and Examples

Historical WPA leaders tend to be high-usage stars on good teams who play many close games. A single game can produce extreme WPA values:

def find_top_wpa_plays(play_by_play, win_prob_model, n=10):
    """
    Identify the highest-WPA plays in a game or season.
    """
    play_by_play = play_by_play.copy()

    # Calculate WPA for each play
    play_by_play['win_prob'] = play_by_play.apply(
        lambda row: win_prob_model.predict({
            'score_diff': row['home_score'] - row['away_score'],
            'seconds_remaining': row['seconds_remaining'],
            'has_possession': 1 if row['possession'] == 'home' else -1,
            'team_rating_diff': row.get('team_rating_diff', 0)
        }),
        axis=1
    )
    play_by_play['wpa'] = play_by_play['win_prob'].diff().abs()

    # Sort by absolute WPA
    top_plays = play_by_play.nlargest(n, 'wpa')

    return top_plays[['game_id', 'player_id', 'play_description',
                       'seconds_remaining', 'wpa']]

21.7.5 Limitations of WPA

WPA should be used carefully due to several limitations:

  1. High variance: WPA is heavily influenced by random factors (which players happen to take shots in close games)
  2. Opportunity bias: Bench players in blowouts have limited WPA opportunity
  3. Attribution challenges: Assists, screens, and defensive plays are hard to credit properly
  4. Not a skill metric: Clutch performance shows little year-to-year consistency

For player evaluation, WPA is best used descriptively ("Player X had the biggest impact on this game") rather than predictively ("Player X is the most clutch player").

21.8 Leverage Index and High-Leverage Situations

21.8.1 Defining Leverage Index

Leverage Index (LI) measures the importance of a game situation relative to the average situation. Originally developed for baseball by Tom Tango, it adapts well to basketball.

The intuition: In a tie game with 30 seconds left, every play matters enormously. In a 25-point blowout, plays barely affect win probability. Leverage Index quantifies this.

$$LI = \frac{|\Delta WP|_{\text{situation}}}{|\Delta WP|_{\text{average}}}$$

Where $|\Delta WP|$ represents the expected absolute change in win probability.

21.8.2 Calculating Leverage Index

def calculate_leverage_index(game_state, win_prob_model,
                             baseline_expected_swing=0.015):
    """
    Calculate leverage index for a given game situation.

    Parameters:
    -----------
    game_state : dict
        Current game state (score_diff, seconds_remaining, etc.)
    win_prob_model : model
        Trained win probability model
    baseline_expected_swing : float
        Average expected WP swing per possession (default ~1.5%)

    Returns:
    --------
    float
        Leverage index (1.0 = average, >1 = above average importance)
    """
    score_diff = game_state['score_diff']
    seconds_remaining = game_state['seconds_remaining']

    # Estimate expected WP swing by simulating outcomes
    # Assume possession could result in 0-4 points for either team

    outcomes = []
    for offensive_points in [0, 2, 3]:
        for defensive_points in [0]:  # Simplify: assume no fast break
            # New score differential after possession
            new_diff = score_diff + offensive_points - defensive_points

            # Approximate new game state
            new_state = game_state.copy()
            new_state['score_diff'] = new_diff
            new_state['seconds_remaining'] = max(0, seconds_remaining - 20)

            # Get new win probability
            current_wp = win_prob_model.predict(game_state)
            new_wp = win_prob_model.predict(new_state)

            outcomes.append(abs(new_wp - current_wp))

    expected_swing = np.mean(outcomes)
    leverage_index = expected_swing / baseline_expected_swing

    return leverage_index

21.8.3 Leverage Index Patterns

Leverage Index follows predictable patterns:

By Time Remaining - Leverage increases exponentially as the game nears its end - Final minute often sees LI > 5.0 - First quarter rarely exceeds LI of 1.5

By Score Differential - Leverage peaks in tie games or 1-possession games - Leverage approaches zero in blowouts - The "leverage cliff" occurs around 15-20 point margins

def generate_leverage_heatmap(win_prob_model):
    """
    Generate heatmap of leverage index across game situations.
    """
    margins = range(-30, 31, 2)
    times = range(0, 2881, 60)

    leverage_grid = np.zeros((len(margins), len(times)))

    for i, margin in enumerate(margins):
        for j, seconds in enumerate(times):
            game_state = {
                'score_diff': margin,
                'seconds_remaining': seconds,
                'has_possession': 1,
                'team_rating_diff': 0
            }
            leverage_grid[i, j] = calculate_leverage_index(
                game_state, win_prob_model
            )

    fig, ax = plt.subplots(figsize=(12, 8))
    im = ax.imshow(leverage_grid, aspect='auto', cmap='YlOrRd',
                   extent=[0, 48, -30, 30])
    ax.set_xlabel('Minutes Remaining')
    ax.set_ylabel('Score Differential')
    ax.set_title('Leverage Index Heatmap')
    plt.colorbar(im, label='Leverage Index')

    return fig, ax

21.8.4 Applications of Leverage Index

Player Usage Decisions Teams should have their best players on the court in high-leverage situations:

def recommend_lineup_by_leverage(game_state, player_ratings,
                                 leverage_threshold=2.0):
    """
    Recommend lineup changes based on leverage.
    """
    leverage = calculate_leverage_index(game_state)

    if leverage > leverage_threshold:
        return {
            'recommendation': 'High leverage - use best lineup',
            'leverage_index': leverage,
            'suggested_players': player_ratings.nlargest(5, 'rating')
        }
    else:
        return {
            'recommendation': 'Normal leverage - manage minutes',
            'leverage_index': leverage
        }

Coaching Decisions Timeout usage, substitution patterns, and play calling should consider leverage.

Player Evaluation Context Adjust WPA by leverage to create "championship leverage WPA" or similar metrics that weight high-stakes situations more heavily.

21.9 Comeback Probability Analysis

21.9.1 Quantifying Comeback Likelihood

Comeback probability is simply the win probability for a trailing team. The model already produces this, but analyzing patterns reveals insights:

def analyze_comeback_probability(score_deficit, seconds_remaining,
                                 win_prob_model, team_rating_diff=0):
    """
    Analyze comeback probability from various deficits.

    Parameters:
    -----------
    score_deficit : int
        Points behind (positive number)
    seconds_remaining : float
        Seconds left in game
    win_prob_model : model
        Trained win probability model
    team_rating_diff : float
        Team strength advantage (positive = trailing team is better)

    Returns:
    --------
    dict
        Comeback probability and context
    """
    game_state = {
        'score_diff': -score_deficit,  # Negative because trailing
        'seconds_remaining': seconds_remaining,
        'has_possession': 0,  # Neutral
        'team_rating_diff': team_rating_diff
    }

    comeback_prob = win_prob_model.predict(game_state)

    # Historical context (would come from database)
    historical_context = get_historical_comeback_rate(
        score_deficit, seconds_remaining
    )

    return {
        'comeback_probability': comeback_prob,
        'historical_rate': historical_context,
        'deficit': score_deficit,
        'time_remaining': seconds_remaining / 60  # minutes
    }

21.9.2 Historical Comeback Rates

Empirical comeback rates provide validation for our models:

Deficit Time Remaining Historical Comeback Rate
5 pts 5:00 ~35%
10 pts 5:00 ~15%
15 pts 5:00 ~5%
20 pts 5:00 ~1%
10 pts 2:00 ~5%
15 pts 2:00 <1%

21.9.3 Identifying Historic Comebacks

The win probability model allows us to rank comebacks by improbability:

def rank_comebacks(game_results, play_by_play, win_prob_model):
    """
    Rank games by comeback improbability.
    """
    comeback_metrics = []

    for game_id in game_results['game_id'].unique():
        game_pbp = play_by_play[play_by_play['game_id'] == game_id]
        game_result = game_results[game_results['game_id'] == game_id].iloc[0]

        # Get winner's perspective
        winner = game_result['winner']

        # Calculate win probability throughout game
        game_pbp['winner_wp'] = game_pbp.apply(
            lambda row: calculate_wp_for_team(row, winner, win_prob_model),
            axis=1
        )

        # Find minimum win probability for winner
        min_wp = game_pbp['winner_wp'].min()
        min_wp_time = game_pbp.loc[game_pbp['winner_wp'].idxmin(), 'seconds_remaining']

        comeback_metrics.append({
            'game_id': game_id,
            'winner': winner,
            'min_win_probability': min_wp,
            'seconds_at_min_wp': min_wp_time,
            'comeback_improbability': 1 - min_wp
        })

    comeback_df = pd.DataFrame(comeback_metrics)
    return comeback_df.sort_values('comeback_improbability', ascending=False)

21.9.4 Momentum and Comeback Dynamics

While "momentum" is often discussed in basketball, statistical evidence for it is mixed. Win probability models can test momentum hypotheses:

def test_momentum_effect(play_by_play, win_prob_model):
    """
    Test whether recent scoring runs predict future performance
    beyond what game state already predicts.
    """
    # Calculate residuals: actual outcome vs predicted
    play_by_play['predicted_wp'] = play_by_play.apply(
        lambda row: win_prob_model.predict(row), axis=1
    )

    # Add momentum indicator (scoring margin last 2 minutes)
    play_by_play['recent_margin'] = (
        play_by_play.groupby('game_id')['points_scored']
        .rolling(window='120s', on='game_timestamp')
        .sum()
        .reset_index(drop=True)
    )

    # Regress actual win on predicted + momentum
    import statsmodels.api as sm

    X = play_by_play[['predicted_wp', 'recent_margin']]
    X = sm.add_constant(X)
    y = play_by_play['team_won']

    model = sm.Logit(y, X).fit()

    return model.summary()

21.10 Broadcast Applications

21.10.1 Win Probability Graphics

Modern NBA broadcasts feature win probability prominently. Effective graphics include:

  1. Live probability bar: Shows current probability with team colors
  2. Probability line chart: Full game history as discussed earlier
  3. Key moment annotations: Labels for major probability swings
  4. Comparison context: "Warriors have 95% win probability - teams win from here 95% of the time"

21.10.2 Narrative Enhancement

Win probability provides objective support for broadcast narratives:

def generate_broadcast_insight(current_wp, wp_history, game_context):
    """
    Generate broadcast-ready insights from win probability data.
    """
    insights = []

    # Current state
    if current_wp > 0.95:
        insights.append(f"Teams in this position win {current_wp:.0%} of the time")
    elif current_wp < 0.05:
        insights.append(f"A comeback from here would be remarkable")

    # Swing detection
    if len(wp_history) > 10:
        recent_swing = wp_history.iloc[-1]['win_probability'] - wp_history.iloc[-5]['win_probability']
        if abs(recent_swing) > 0.15:
            direction = "gained" if recent_swing > 0 else "lost"
            insights.append(f"That run {direction} {abs(recent_swing):.0%} win probability")

    # Game volatility
    if len(wp_history) > 20:
        volatility = wp_history['win_probability'].diff().abs().mean()
        if volatility > 0.03:
            insights.append("This has been an unusually volatile game")

    return insights

21.10.3 Second-Screen Applications

Mobile companion apps enhance the broadcast experience:

  • Play-by-play WPA: Show impact of each play in real-time
  • Player WPA leaderboards: Who's contributing most?
  • "What if" scenarios: What if they had made that shot?
  • Historical comparison: "This is similar to Game 7 of the 2016 Finals"

21.10.4 Responsible Communication

Win probability should be communicated responsibly:

  1. Uncertainty acknowledgment: "The model estimates..." rather than definitive statements
  2. Historical grounding: Always connect to empirical frequencies
  3. Avoiding determinism: Emphasize that low-probability events happen
  4. Context provision: Explain what factors drive the probability

21.11 Complete Model Building with Python

21.11.1 Full Implementation

Here we provide a complete, production-ready win probability model implementation:

"""
Complete Win Probability Model for Basketball
"""

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import brier_score_loss, log_loss
import matplotlib.pyplot as plt

class BasketballWinProbabilityModel:
    """
    Production-ready win probability model for basketball.
    """

    def __init__(self, include_team_strength=True):
        """
        Initialize the model.

        Parameters:
        -----------
        include_team_strength : bool
            Whether to include pre-game team strength differential
        """
        self.include_team_strength = include_team_strength
        self.model = None
        self.scaler = StandardScaler()
        self.feature_columns = None
        self.is_fitted = False

    def _engineer_features(self, df):
        """
        Engineer features from raw game state data.
        """
        df = df.copy()

        # Time transformations
        df['log_seconds'] = np.log1p(df['seconds_remaining'])
        df['sqrt_seconds'] = np.sqrt(df['seconds_remaining'])

        # Score-time interactions
        df['score_x_sqrt_time'] = df['score_diff'] * df['sqrt_seconds']
        df['score_x_log_time'] = df['score_diff'] * df['log_seconds']

        # Squared terms
        df['score_squared'] = df['score_diff'] ** 2

        # Overtime indicator
        df['is_overtime'] = (df['seconds_remaining'] <= 0).astype(int)

        return df

    def _get_feature_columns(self):
        """Define feature columns based on configuration."""
        features = [
            'score_diff',
            'log_seconds',
            'sqrt_seconds',
            'score_x_sqrt_time',
            'score_x_log_time',
            'score_squared',
            'has_possession',
            'is_overtime'
        ]

        if self.include_team_strength:
            features.append('team_rating_diff')

        return features

    def fit(self, X, y):
        """
        Fit the win probability model.

        Parameters:
        -----------
        X : DataFrame
            Must contain: score_diff, seconds_remaining, has_possession
            Optionally: team_rating_diff
        y : array-like
            Binary win indicator (1 = win, 0 = loss)

        Returns:
        --------
        self
        """
        # Engineer features
        X_engineered = self._engineer_features(X)
        self.feature_columns = self._get_feature_columns()

        # Extract feature matrix
        X_matrix = X_engineered[self.feature_columns].values

        # Scale features
        X_scaled = self.scaler.fit_transform(X_matrix)

        # Fit logistic regression
        self.model = LogisticRegression(
            penalty='l2',
            C=1.0,
            solver='lbfgs',
            max_iter=1000,
            random_state=42
        )
        self.model.fit(X_scaled, y)

        self.is_fitted = True
        return self

    def predict_proba(self, X):
        """
        Predict win probability.

        Parameters:
        -----------
        X : DataFrame or dict
            Game state information

        Returns:
        --------
        array
            Win probabilities
        """
        if not self.is_fitted:
            raise ValueError("Model must be fitted before prediction")

        # Handle single observation as dict
        if isinstance(X, dict):
            X = pd.DataFrame([X])

        # Engineer features
        X_engineered = self._engineer_features(X)
        X_matrix = X_engineered[self.feature_columns].values

        # Scale and predict
        X_scaled = self.scaler.transform(X_matrix)
        probabilities = self.model.predict_proba(X_scaled)[:, 1]

        return probabilities

    def evaluate(self, X, y):
        """
        Evaluate model performance.

        Parameters:
        -----------
        X : DataFrame
            Test features
        y : array-like
            True outcomes

        Returns:
        --------
        dict
            Evaluation metrics
        """
        predictions = self.predict_proba(X)

        return {
            'brier_score': brier_score_loss(y, predictions),
            'log_loss': log_loss(y, predictions),
            'baseline_brier': brier_score_loss(y, [0.5] * len(y)),
            'n_observations': len(y)
        }

    def plot_calibration(self, X, y, n_bins=10, ax=None):
        """
        Create calibration plot.
        """
        from sklearn.calibration import calibration_curve

        predictions = self.predict_proba(X)

        fraction_positive, mean_predicted = calibration_curve(
            y, predictions, n_bins=n_bins, strategy='uniform'
        )

        if ax is None:
            fig, ax = plt.subplots(figsize=(8, 8))

        ax.plot([0, 1], [0, 1], 'k--', label='Perfect Calibration')
        ax.plot(mean_predicted, fraction_positive, 's-',
                markersize=8, label='Model')

        ax.set_xlabel('Mean Predicted Probability')
        ax.set_ylabel('Actual Win Rate')
        ax.set_title('Win Probability Calibration')
        ax.legend()
        ax.grid(True, alpha=0.3)
        ax.set_xlim([0, 1])
        ax.set_ylim([0, 1])

        return ax

    def get_coefficients(self):
        """Return model coefficients for interpretation."""
        if not self.is_fitted:
            raise ValueError("Model must be fitted first")

        return pd.DataFrame({
            'feature': self.feature_columns,
            'coefficient': self.model.coef_[0],
            'scaled_importance': np.abs(self.model.coef_[0])
        }).sort_values('scaled_importance', ascending=False)


def generate_synthetic_training_data(n_games=1000, plays_per_game=200):
    """
    Generate synthetic training data for model development.

    This simulates play-by-play data with realistic distributions.
    """
    np.random.seed(42)

    records = []

    for game_id in range(n_games):
        # Pre-game team strength differential (point spread)
        team_rating_diff = np.random.normal(0, 6)

        # Simulate game
        home_score = 0
        away_score = 0

        # Random winner (influenced by team strength)
        home_win_prob = 1 / (1 + np.exp(-team_rating_diff / 10))
        home_wins = np.random.random() < home_win_prob

        for play in range(plays_per_game):
            # Time remaining
            seconds_remaining = max(0, 2880 - (play * 2880 / plays_per_game) +
                                   np.random.normal(0, 10))

            # Score changes (biased toward eventual winner)
            if home_wins:
                home_score += np.random.choice([0, 0, 0, 2, 2, 3],
                                               p=[0.4, 0.2, 0.1, 0.15, 0.1, 0.05])
                away_score += np.random.choice([0, 0, 0, 2, 2, 3],
                                               p=[0.45, 0.2, 0.1, 0.13, 0.08, 0.04])
            else:
                home_score += np.random.choice([0, 0, 0, 2, 2, 3],
                                               p=[0.45, 0.2, 0.1, 0.13, 0.08, 0.04])
                away_score += np.random.choice([0, 0, 0, 2, 2, 3],
                                               p=[0.4, 0.2, 0.1, 0.15, 0.1, 0.05])

            # Possession indicator
            has_possession = np.random.choice([-1, 0, 1])

            records.append({
                'game_id': game_id,
                'seconds_remaining': seconds_remaining,
                'score_diff': home_score - away_score,
                'has_possession': has_possession,
                'team_rating_diff': team_rating_diff,
                'home_wins': home_wins
            })

    return pd.DataFrame(records)


# Example usage
if __name__ == "__main__":
    # Generate training data
    print("Generating synthetic training data...")
    data = generate_synthetic_training_data(n_games=5000)

    # Split data
    train_data, test_data = train_test_split(
        data, test_size=0.2, random_state=42
    )

    # Train model
    print("Training win probability model...")
    model = BasketballWinProbabilityModel(include_team_strength=True)
    model.fit(
        train_data[['score_diff', 'seconds_remaining', 'has_possession',
                    'team_rating_diff']],
        train_data['home_wins']
    )

    # Evaluate
    print("\nModel Evaluation:")
    metrics = model.evaluate(
        test_data[['score_diff', 'seconds_remaining', 'has_possession',
                   'team_rating_diff']],
        test_data['home_wins']
    )
    for metric, value in metrics.items():
        print(f"  {metric}: {value:.4f}")

    # Show coefficients
    print("\nModel Coefficients:")
    print(model.get_coefficients().to_string(index=False))

    # Example predictions
    print("\nExample Predictions:")
    test_states = [
        {'score_diff': 0, 'seconds_remaining': 2880, 'has_possession': 0,
         'team_rating_diff': 0},
        {'score_diff': 10, 'seconds_remaining': 300, 'has_possession': 1,
         'team_rating_diff': 0},
        {'score_diff': -5, 'seconds_remaining': 60, 'has_possession': 1,
         'team_rating_diff': 3},
    ]

    for state in test_states:
        prob = model.predict_proba(state)[0]
        print(f"  Score diff: {state['score_diff']:+d}, "
              f"Time: {state['seconds_remaining']/60:.1f} min, "
              f"WP: {prob:.1%}")

21.11.2 Model Validation

Rigorous validation ensures model reliability:

def comprehensive_validation(model, test_data):
    """
    Perform comprehensive model validation.
    """
    X_test = test_data[['score_diff', 'seconds_remaining', 'has_possession',
                        'team_rating_diff']]
    y_test = test_data['home_wins']

    predictions = model.predict_proba(X_test)

    # 1. Overall metrics
    print("=== Overall Performance ===")
    metrics = model.evaluate(X_test, y_test)
    for k, v in metrics.items():
        print(f"{k}: {v:.4f}")

    # 2. By time period
    print("\n=== By Time Remaining ===")
    time_bins = [
        ('Q4 Final 2 min', test_data['seconds_remaining'] <= 120),
        ('Q4 2-5 min', (test_data['seconds_remaining'] > 120) &
                       (test_data['seconds_remaining'] <= 300)),
        ('Q4 >5 min', (test_data['seconds_remaining'] > 300) &
                      (test_data['seconds_remaining'] <= 720)),
        ('Q1-Q3', test_data['seconds_remaining'] > 720)
    ]

    for name, mask in time_bins:
        if mask.sum() > 100:
            brier = brier_score_loss(y_test[mask], predictions[mask])
            print(f"{name}: Brier={brier:.4f} (n={mask.sum()})")

    # 3. By score differential
    print("\n=== By Score Differential ===")
    margin_bins = [
        ('Close (0-5)', np.abs(test_data['score_diff']) <= 5),
        ('Moderate (6-10)', (np.abs(test_data['score_diff']) > 5) &
                           (np.abs(test_data['score_diff']) <= 10)),
        ('Comfortable (11-20)', (np.abs(test_data['score_diff']) > 10) &
                                (np.abs(test_data['score_diff']) <= 20)),
        ('Blowout (>20)', np.abs(test_data['score_diff']) > 20)
    ]

    for name, mask in margin_bins:
        if mask.sum() > 100:
            brier = brier_score_loss(y_test[mask], predictions[mask])
            print(f"{name}: Brier={brier:.4f} (n={mask.sum()})")

    # 4. Calibration across probability ranges
    print("\n=== Calibration by Predicted Probability ===")
    prob_bins = pd.cut(predictions, bins=[0, 0.2, 0.4, 0.6, 0.8, 1.0])

    for bin_range in prob_bins.unique():
        mask = prob_bins == bin_range
        if mask.sum() > 100:
            actual_rate = y_test[mask].mean()
            predicted_mean = predictions[mask].mean()
            print(f"{bin_range}: Predicted={predicted_mean:.3f}, "
                  f"Actual={actual_rate:.3f}, n={mask.sum()}")

21.12 Summary

Win probability modeling represents a cornerstone of modern basketball analytics, transforming subjective game narratives into quantifiable metrics. Through this chapter, we have explored:

  1. Theoretical foundations: Understanding win probability as conditional probability given game state
  2. Model construction: Building logistic regression models with appropriate feature engineering
  3. Calibration: Ensuring predictions match empirical frequencies
  4. Applications: From player evaluation (WPA) to broadcast enhancement
  5. Advanced concepts: Leverage index, comeback analysis, and real-time systems

Win probability models are not perfect - they cannot capture every nuance of basketball strategy, player matchups, or momentum. However, they provide a rigorous, objective framework for understanding game dynamics that complements traditional analysis.

As data availability and computational power continue to grow, win probability models will become increasingly sophisticated, incorporating player-specific effects, tracking data, and real-time adjustments. The foundational concepts presented here will remain relevant even as implementation details evolve.

The next chapter will build on these concepts to explore clutch performance analysis, examining whether some players truly perform better in high-leverage situations or whether "clutch" is largely a product of randomness and narrative.

Chapter References

  1. Tango, T., Lichtman, M., & Dolphin, A. (2007). The Book: Playing the Percentages in Baseball. Potomac Books.

  2. Kubatko, J., Oliver, D., Pelton, K., & Rosenbaum, D. T. (2007). A starting point for analyzing basketball statistics. Journal of Quantitative Analysis in Sports, 3(3).

  3. Stern, H. S. (1994). A Brownian motion model for the progress of sports scores. Journal of the American Statistical Association, 89(427), 1128-1134.

  4. Bashuk, M. (2012). Using cumulative win probabilities to predict NCAA basketball performance. MIT Sloan Sports Analytics Conference.

  5. Lopez, M. J., & Matthews, G. J. (2015). Building an NCAA men's basketball predictive model and quantifying its success. Journal of Quantitative Analysis in Sports, 11(1), 5-12.

  6. Deshpande, S. K., & Jensen, S. T. (2016). Estimating an NBA player's impact on his team's chances of winning. Journal of Quantitative Analysis in Sports, 12(2), 51-72.

  7. Cervone, D., D'Amour, A., Bornn, L., & Goldsberry, K. (2016). A multiresolution stochastic process model for predicting basketball possession outcomes. Journal of the American Statistical Association, 111(514), 585-599.

  8. Sandholtz, N., Mortensen, J., & Bornn, L. (2019). Measuring and attributing wins in basketball. arXiv preprint arXiv:1901.05521.


Next Chapter: Chapter 22 - Clutch Performance Analysis

Previous Chapter: Chapter 20 - Game Flow Analysis