Case Study 42.1: Applying Causal Inference to NBA Rest Advantage

Overview

Dr. Aisha Patel, a sports analytics researcher at a university, had observed that NBA teams playing on zero rest (back-to-back games) consistently underperformed relative to expectations. The pattern was well-known in the betting community: unders hit more often in back-to-back games, and the tired team tended to underperform the spread.

But Aisha noticed something that the betting community treated as settled that was not settled at all from a causal perspective. The observed correlation between fatigue and underperformance could reflect multiple causal pathways, and some of them had implications that others missed. Her investigation using formal causal inference methods revealed a more nuanced picture --- and a more durable betting edge than the surface correlation suggested.

The Causal Question

The naive analysis is simple: teams on back-to-back games score fewer points and cover the spread less often. The betting implication seems obvious: bet unders and bet against the fatigued team.

But Aisha identified several competing causal explanations:

  1. Direct fatigue effect: Physical tiredness directly reduces performance (scoring, defensive effort, free throw shooting).
  2. Rotation effect: Coaches rest star players in back-to-back games, and the reduced talent causes the performance decline.
  3. Travel effect: Many back-to-backs involve travel between cities, and the travel itself (not the fatigue from playing) drives the decline.
  4. Scheduling confound: Teams with difficult schedules (many back-to-backs) may be systematically different in other ways from teams with easier schedules.

Each explanation has different betting implications. If the market correctly prices the rotation effect but underprices the direct fatigue effect, there is an edge in the residual. If the market overprices back-to-backs because casual bettors overreact to the narrative, there might be contrarian value.

Building the DAG

Aisha began by specifying a DAG for the back-to-back effect.

"""
Case Study 42.1: Causal Inference for NBA Rest Advantage
=========================================================

Demonstrates DAG construction, instrumental variable estimation,
and causal effect decomposition for understanding the back-to-back
game effect in NBA betting.
"""

import numpy as np
import pandas as pd
from typing import Dict, List, Tuple
from sklearn.linear_model import LinearRegression


def generate_causal_nba_data(
    n_team_seasons: int = 150,
    games_per_team: int = 82,
    seed: int = 42,
) -> pd.DataFrame:
    """Generate synthetic NBA data with known causal structure.

    The data-generating process encodes explicit causal relationships:
        talent -> performance (direct)
        back_to_back -> fatigue -> performance (mediated)
        back_to_back -> rotation (star_rested) -> performance (mediated)
        travel_distance -> fatigue (confounded with back_to_back)
        altitude -> fatigue (instrument for travel)
        talent -> schedule_strength (reverse causation concern)

    Args:
        n_team_seasons: Number of team-seasons to generate.
        games_per_team: Games per team per season.
        seed: Random seed.

    Returns:
        DataFrame with known causal structure for analysis.
    """
    np.random.seed(seed)

    records: List[Dict] = []
    for ts in range(n_team_seasons):
        talent = np.random.normal(0, 5)

        for game in range(games_per_team):
            is_b2b = np.random.random() < 0.20
            travel_distance = (
                np.random.exponential(500) if is_b2b
                else np.random.exponential(200)
            )
            altitude = np.random.choice([0, 0, 0, 1], p=[0.85, 0.05, 0.05, 0.05])

            fatigue = (
                3.0 * is_b2b
                + 0.002 * travel_distance
                + 1.5 * altitude
                + np.random.normal(0, 1)
            )
            fatigue = max(fatigue, 0)

            star_rested = is_b2b and np.random.random() < 0.40

            performance_noise = np.random.normal(0, 8)
            performance = (
                100
                + 1.2 * talent
                - 2.5 * fatigue
                - 5.0 * star_rested
                + performance_noise
            )

            market_expected = (
                100
                + 1.0 * talent
                - 1.8 * (3.0 * is_b2b)
                - 4.0 * star_rested
            )

            beat_spread = performance - market_expected

            records.append({
                "team_season": ts,
                "game_num": game,
                "talent": round(talent, 2),
                "is_b2b": int(is_b2b),
                "travel_distance": round(travel_distance, 0),
                "altitude": altitude,
                "fatigue": round(fatigue, 2),
                "star_rested": int(star_rested),
                "performance": round(performance, 1),
                "market_expected": round(market_expected, 1),
                "beat_spread": round(beat_spread, 1),
            })

    return pd.DataFrame(records)


class CausalAnalyzer:
    """Perform causal inference analyses on NBA fatigue data.

    Implements OLS, instrumental variable estimation, and
    mediation analysis to decompose the back-to-back effect.

    Args:
        data: DataFrame with the generated causal data.
    """

    def __init__(self, data: pd.DataFrame) -> None:
        self.data = data.copy()

    def naive_ols(self) -> Dict:
        """Run naive OLS regression of performance on back-to-back indicator.

        This estimate is potentially biased by confounders (talent
        affects both schedule and performance).

        Returns:
            Dictionary of regression results.
        """
        X = self.data[["is_b2b"]].values
        y = self.data["performance"].values

        model = LinearRegression()
        model.fit(X, y)

        return {
            "method": "Naive OLS",
            "b2b_effect": round(model.coef_[0], 3),
            "intercept": round(model.intercept_, 3),
            "r_squared": round(model.score(X, y), 4),
        }

    def controlled_ols(self) -> Dict:
        """OLS with controls for observable confounders.

        Adds talent and star_rested as controls.

        Returns:
            Dictionary of regression results.
        """
        X = self.data[["is_b2b", "talent", "star_rested"]].values
        y = self.data["performance"].values

        model = LinearRegression()
        model.fit(X, y)

        return {
            "method": "Controlled OLS",
            "b2b_effect": round(model.coef_[0], 3),
            "talent_effect": round(model.coef_[1], 3),
            "star_rested_effect": round(model.coef_[2], 3),
            "r_squared": round(model.score(X, y), 4),
        }

    def iv_estimation(self) -> Dict:
        """Two-stage least squares using altitude as instrument for fatigue.

        Altitude affects fatigue (through physical demands) but should
        not directly affect performance except through fatigue.

        Returns:
            Dictionary of IV estimation results.
        """
        Z = self.data[["altitude", "talent"]].values
        treatment = self.data["fatigue"].values.reshape(-1, 1)
        y = self.data["performance"].values.reshape(-1, 1)

        first_stage = LinearRegression()
        first_stage.fit(Z, treatment)
        treatment_hat = first_stage.predict(Z)

        ss_total = np.sum((treatment - treatment.mean()) ** 2)
        ss_res = np.sum((treatment - treatment_hat) ** 2)
        f_stat = ((ss_total - ss_res) / 1) / (ss_res / (len(treatment) - 3))

        second_stage_X = np.hstack([treatment_hat, self.data[["talent"]].values])
        second_stage = LinearRegression()
        second_stage.fit(second_stage_X, y)

        ols_X = np.hstack([treatment, self.data[["talent"]].values])
        ols_model = LinearRegression()
        ols_model.fit(ols_X, y)

        return {
            "method": "2SLS IV (instrument: altitude)",
            "causal_fatigue_effect": round(second_stage.coef_[0][0], 3),
            "ols_fatigue_effect": round(ols_model.coef_[0][0], 3),
            "first_stage_f": round(f_stat, 1),
            "strong_instrument": f_stat > 10,
            "bias": round(ols_model.coef_[0][0] - second_stage.coef_[0][0], 3),
        }

    def mediation_analysis(self) -> Dict:
        """Decompose the total back-to-back effect into pathways.

        Estimates:
            - Total effect: back-to-back -> performance
            - Direct fatigue pathway: b2b -> fatigue -> performance
            - Rotation pathway: b2b -> star_rested -> performance

        Returns:
            Dictionary of mediation analysis results.
        """
        X_total = self.data[["is_b2b", "talent"]].values
        y = self.data["performance"].values
        total_model = LinearRegression().fit(X_total, y)
        total_effect = total_model.coef_[0]

        X_with_mediators = self.data[
            ["is_b2b", "talent", "fatigue", "star_rested"]
        ].values
        direct_model = LinearRegression().fit(X_with_mediators, y)
        direct_effect = direct_model.coef_[0]

        indirect_effect = total_effect - direct_effect

        fatigue_on_b2b = LinearRegression().fit(
            self.data[["is_b2b"]].values, self.data["fatigue"].values
        )
        star_on_b2b = LinearRegression().fit(
            self.data[["is_b2b"]].values, self.data["star_rested"].values
        )

        fatigue_pathway = fatigue_on_b2b.coef_[0] * direct_model.coef_[2]
        rotation_pathway = star_on_b2b.coef_[0] * direct_model.coef_[3]

        return {
            "total_effect": round(total_effect, 3),
            "direct_effect_of_b2b": round(direct_effect, 3),
            "indirect_through_mediators": round(indirect_effect, 3),
            "fatigue_pathway": round(fatigue_pathway, 3),
            "rotation_pathway": round(rotation_pathway, 3),
            "pct_via_fatigue": round(
                abs(fatigue_pathway) / abs(total_effect) * 100, 1
            ),
            "pct_via_rotation": round(
                abs(rotation_pathway) / abs(total_effect) * 100, 1
            ),
        }

    def betting_implication(self) -> Dict:
        """Analyze whether the market correctly prices the b2b effect.

        Compares the total causal effect to the market's adjustment.

        Returns:
            Dictionary of betting-relevant findings.
        """
        b2b_games = self.data[self.data["is_b2b"] == 1]
        non_b2b = self.data[self.data["is_b2b"] == 0]

        avg_beat_spread_b2b = b2b_games["beat_spread"].mean()
        avg_beat_spread_non = non_b2b["beat_spread"].mean()

        return {
            "n_b2b_games": len(b2b_games),
            "n_non_b2b_games": len(non_b2b),
            "avg_beat_spread_b2b": round(avg_beat_spread_b2b, 2),
            "avg_beat_spread_non_b2b": round(avg_beat_spread_non, 2),
            "market_mispricing": round(avg_beat_spread_b2b - avg_beat_spread_non, 2),
            "edge_direction": (
                "B2B teams underpriced (bet for them)"
                if avg_beat_spread_b2b > avg_beat_spread_non
                else "B2B teams overpriced (bet against them)"
            ),
        }

    def full_analysis(self) -> None:
        """Run and print all causal analyses."""
        print("CAUSAL ANALYSIS: NBA Back-to-Back Game Effect")
        print("=" * 55)

        naive = self.naive_ols()
        print(f"\n1. Naive OLS:")
        print(f"   B2B effect on performance: {naive['b2b_effect']:.3f} points")

        controlled = self.controlled_ols()
        print(f"\n2. Controlled OLS (with talent and star_rested):")
        print(f"   B2B effect: {controlled['b2b_effect']:.3f} points")
        print(f"   Talent effect: {controlled['talent_effect']:.3f}")
        print(f"   Star rested effect: {controlled['star_rested_effect']:.3f}")

        iv = self.iv_estimation()
        print(f"\n3. IV Estimation (altitude as instrument):")
        print(f"   Causal fatigue effect: {iv['causal_fatigue_effect']:.3f}")
        print(f"   OLS fatigue effect: {iv['ols_fatigue_effect']:.3f}")
        print(f"   First-stage F: {iv['first_stage_f']:.1f} "
              f"(strong: {iv['strong_instrument']})")
        print(f"   Estimated bias in OLS: {iv['bias']:.3f}")

        mediation = self.mediation_analysis()
        print(f"\n4. Mediation Analysis:")
        print(f"   Total B2B effect: {mediation['total_effect']:.3f}")
        print(f"   Via fatigue: {mediation['fatigue_pathway']:.3f} "
              f"({mediation['pct_via_fatigue']:.1f}%)")
        print(f"   Via rotation: {mediation['rotation_pathway']:.3f} "
              f"({mediation['pct_via_rotation']:.1f}%)")

        betting = self.betting_implication()
        print(f"\n5. Betting Implication:")
        print(f"   B2B avg beat spread: {betting['avg_beat_spread_b2b']:+.2f}")
        print(f"   Non-B2B avg beat spread: {betting['avg_beat_spread_non_b2b']:+.2f}")
        print(f"   Market mispricing: {betting['market_mispricing']:+.2f}")
        print(f"   Direction: {betting['edge_direction']}")

Aisha's Key Findings

Running the causal analysis on five seasons of data revealed a crucial insight:

The market correctly priced the rotation effect but underpriced the residual fatigue effect. When star players were announced as resting, the market moved substantially --- enough to fully account for the talent reduction. But the remaining fatigue effect on the non-rested players was incompletely priced.

The mediation analysis showed that approximately 60% of the total back-to-back performance decline came through the fatigue pathway and 35% through the rotation pathway. The market was pricing about 75% of the rotation pathway but only about 40% of the fatigue pathway.

The IV analysis confirmed that the causal effect of fatigue was larger than the OLS estimate suggested. The OLS estimate was attenuated because fatigue measurement was noisy and because some back-to-back games involved less travel (home-home back-to-backs), creating measurement error. The IV estimate, using altitude as an instrument, found a fatigue effect about 30% larger than OLS.

Betting Strategy Implications

  1. Back-to-back games where stars play are the best opportunity. When stars are not rested, the market does not fully adjust for the fatigue of those stars. The edge is in the residual fatigue that the market does not separately price.

  2. Travel distance matters more than the back-to-back label. A home-home back-to-back has much less fatigue than a road-road back-to-back with long travel. The market treats all back-to-backs similarly, creating an edge for bettors who account for the specific travel pattern.

  3. The edge is durable because the causal mechanism is real. Unlike correlations that can break when market conditions change, the fatigue effect is grounded in human physiology. Players get tired. This is not a statistical artifact that will disappear when the market adapts.

Discussion Questions

  1. Aisha used altitude as an instrument for fatigue. What potential violations of the exclusion restriction exist for this instrument? Can you think of a better instrument?

  2. The mediation analysis assumes no interaction between the fatigue and rotation pathways. Is this realistic? How might interactions affect the betting strategy?

  3. If you published this research, sportsbooks might start pricing the fatigue effect more accurately. How would you monitor whether your edge is decaying, and what would you do if it were?

  4. The case study focuses on unders, but what about sides (against the spread)? Does the causal analysis predict that backs-to-back teams will systematically underperform the spread?