""" Tutorial 90 - Rest days and back-to-backs: measuring the schedule as a player. Every NBA results log quietly carries a second dataset: the schedule itself. From nothing but game dates you can compute how much rest each team had before every game - and then measure what a night of rest is actually worth. This tutorial reshapes the bundled 2023-24 results log (1,231 rows) into 2,462 team-games, computes per-team rest gaps with one groupby().diff(), and answers three questions honestly: how often teams play on zero rest, what playing the second night of a back-to-back does to win rates, and what happens when one side is rested and the other is not. The row-dropping lesson from the standings tutorial returns with a twist: the In-Season Tournament final is the row you must DROP for standings, but for schedule math you must KEEP it - both teams really did spend December 9 in Las Vegas playing it, and deleting the game would falsify the rest gaps of their next games. Which rows you drop depends on the question, not on the file. Runs entirely offline from the bundled nba_home_results.csv next to this script (no API, no key). Run: python downloads/90_rest_days_and_back_to_backs.py """ import os import matplotlib.pyplot as plt import numpy as np import pandas as pd import sdt_common as sdt sdt.init("rest-days-and-back-to-backs") HERE = os.path.dirname(os.path.abspath(__file__)) CSV = os.path.join(HERE, "nba_home_results.csv") games = pd.read_csv(CSV, parse_dates=["date"]) games["margin"] = games["home_pts"] - games["away_pts"] games["game_id"] = games.index # The 2023-24 log holds 1,230 regular-season games plus the In-Season Tournament # final (2023-12-09, coded as a Lakers home game but played in Las Vegas). Two # tables, two questions: the SCHEDULE table keeps it (the game was really # played, so it really consumed a calendar night); the OUTCOME table drops it # (it does not count in the standings, and its "home court" was neutral). IST_FINAL = games["date"] == "2023-12-09" outcomes = games[~IST_FINAL].copy() with sdt.snippet("shape"): print(f"{len(games)} games in the log, {games.date.min().date()} to {games.date.max().date()}") print("\nThe one row standings must drop and schedule math must keep:") sdt.show_df(games.loc[IST_FINAL, ["date", "away_team", "away_pts", "home_team", "home_pts"]]) print(f"\nschedule table: {len(games)} games outcome table: {len(outcomes)} games") # --- one game, two team-games ------------------------------------------------------- home = pd.DataFrame({ "game_id": games.game_id, "date": games.date, "team": games.home_team, "opponent": games.away_team, "is_home": True, "pts_for": games.home_pts, "pts_against": games.away_pts, }) away = pd.DataFrame({ "game_id": games.game_id, "date": games.date, "team": games.away_team, "opponent": games.home_team, "is_home": False, "pts_for": games.away_pts, "pts_against": games.home_pts, }) tg = pd.concat([home, away], ignore_index=True).sort_values(["team", "date"]) # Rest = full days off between games: play Tue then Wed -> gap 1 day -> 0 rest. tg["gap"] = tg.groupby("team")["date"].diff().dt.days tg["rest"] = tg["gap"] - 1 tg["rest_bucket"] = tg["rest"].map( lambda r: np.nan if pd.isna(r) else ("3+" if r >= 3 else str(int(r)))) with sdt.snippet("long"): counts = tg["rest_bucket"].value_counts() total = counts.sum() print(f"{len(tg)} team-games from {len(games)} games; " f"{tg['rest'].isna().sum()} season openers have no prior game\n") print(f"rest before a game (full days off), share of the {total:,} " f"team-games with a known prior game:") for b in ["0", "1", "2", "3+"]: print(f" {b:>2} days rest {counts[b]:5d} {100 * counts[b] / total:4.1f}%") b2b = tg[tg.rest == 0].groupby("team").size() print(f"\nsecond nights of a back-to-back per team: " f"mean {b2b.mean():.1f}, min {b2b.min()}, max {b2b.max()}") road_share = 100 * (~tg[tg.rest == 0].is_home).mean() print(f"share of zero-rest games played on the road: {road_share:.1f}%") # --- outcomes: join rest onto the game rows, drop the IST final ------------------- rest_cols = tg.set_index(["game_id", "team"])["rest"] outcomes["home_rest"] = outcomes.apply( lambda g: rest_cols.get((g.game_id, g.home_team), np.nan), axis=1) outcomes["away_rest"] = outcomes.apply( lambda g: rest_cols.get((g.game_id, g.away_team), np.nan), axis=1) known = outcomes.dropna(subset=["home_rest", "away_rest"]).copy() with sdt.snippet("winrates"): base = 100 * (known.margin > 0).mean() print(f"outcome sample: {len(known)} games with both rests known") print(f"home win rate overall: {base:.1f}%\n") rows = [ ("home on zero rest", known[known.home_rest == 0]), ("away on zero rest", known[known.away_rest == 0]), ("both on zero rest", known[(known.home_rest == 0) & (known.away_rest == 0)]), ("neither on zero rest", known[(known.home_rest > 0) & (known.away_rest > 0)]), ] for label, d in rows: print(f" {label:<22} home wins {100 * (d.margin > 0).mean():4.1f}% " f"avg margin {d.margin.mean():+5.2f} n={len(d)}") tired = tg[(tg.rest == 0)].merge( tg[["game_id", "team", "rest"]], left_on=["game_id", "opponent"], right_on=["game_id", "team"], suffixes=("", "_opp")) tired = tired[tired.rest_opp > 0] tired = tired[tired.game_id.isin(known.game_id)] wins = (tired.pts_for > tired.pts_against).mean() net = (tired.pts_for - tired.pts_against).mean() print(f"\nthe classic cut - on zero rest against a rested opponent:") print(f" win rate {100 * wins:.1f}%, net margin {net:+.2f} per game, " f"n={len(tired)} ({100 * (~tired.is_home).mean():.0f}% on the road)") with sdt.snippet("advantage"): known["rest_diff"] = (known.home_rest - known.away_rest).clip(-2, 2) print("home win rate by rest difference (home rest minus away rest, clipped):\n") for d in [-2, -1, 0, 1, 2]: s = known[known.rest_diff == d] tag = {-2: "away much more rested", -1: "away more rested", 0: "equal rest", 1: "home more rested", 2: "home much more rested"}[d] print(f" {d:+d} {tag:<24} {100 * (s.margin > 0).mean():4.1f}% " f"margin {s.margin.mean():+5.2f} n={len(s)}") r = np.corrcoef(known.rest_diff, known.margin)[0, 1] print(f"\ncorr(rest difference, home margin) = {r:.3f} - real, and small") # --- the exhibit ------------------------------------------------------------------ HOOP = sdt.sport_color("basketball") fig, ax = plt.subplots(figsize=(8.8, 5.0)) diffs = [-2, -1, 0, 1, 2] sub = [known[known.rest_diff == d] for d in diffs] rates = [100 * (s.margin > 0).mean() for s in sub] ns = [len(s) for s in sub] base = 100 * (known.margin > 0).mean() bars = ax.bar([str(d) for d in diffs], rates, color=HOOP, alpha=0.9, zorder=3) ax.axhline(base, color="#4A4F58", lw=1, ls="--", zorder=2) ax.annotate(f"all games: {base:.1f}%", (0.02, base + 0.6), fontsize=9, color="#4A4F58", xycoords=("axes fraction", "data")) for bar, rate, n in zip(bars, rates, ns): ax.annotate(f"{rate:.1f}%\nn={n}", (bar.get_x() + bar.get_width() / 2, rate), ha="center", va="bottom", fontsize=9, xytext=(0, 3), textcoords="offset points") ax.set_xlabel("home rest minus away rest (days, clipped at +/-2)") ax.set_ylabel("home win rate, %") ax.set_ylim(0, 78) ax.set_title("Home court is worth more when the schedule tilts your way (2023-24)") sdt.save_fig(fig, "rest_gap", source="bundled 2023-24 NBA results log")