""" Tutorial 88 - Team luck with PDO: the number that flags hot streaks. PDO is on-ice shooting percentage plus save percentage, scaled so the league averages 100 by construction. The folk claim is "PDO is luck - it always regresses to 100." This tutorial computes the honest version from the NHL's keyless public stats API, using the two most recent completed seasons (2024-25 and 2025-26, all 82 games each): the league identity that pins the average at exactly 100, the same-season correlation that shows PDO flavor all over the standings, the year-over-year test that shows how hard it regresses - and the decomposition nobody quotes: the shooting half of PDO carries real talent across seasons while the save half barely persists at all, which is the opposite of where fans put their faith. Pulls live from api.nhle.com (no key needed) and saves the tidy two-season table to nhl_team_pdo_two_seasons.csv next to this script, so every later step - and the in-page runner - works offline from the bundled file. The Utah franchise renamed between the two seasons (Hockey Club -> Mammoth); the loader maps the names so the franchise stays one row, and says so. Run: python downloads/88_team_luck_with_pdo.py """ import os import matplotlib.pyplot as plt import numpy as np import pandas as pd import sdt_common as sdt sdt.init("team-luck-with-pdo") HERE = os.path.dirname(os.path.abspath(__file__)) CSV = os.path.join(HERE, "nhl_team_pdo_two_seasons.csv") SEASONS = {"2024-25": 20242025, "2025-26": 20252026} # One franchise, two names: Utah entered 2024-25 as the Hockey Club and became # the Mammoth for 2025-26. Map to the current name so year-over-year joins work. RENAME = {"Utah Hockey Club": "Utah Mammoth"} def fetch_seasons(): ses = sdt.polite_session() frames = [] for label, sid in SEASONS.items(): url = ("https://api.nhle.com/stats/rest/en/team/summary" f"?cayenneExp=seasonId={sid}%20and%20gameTypeId=2") data = ses.get(url, timeout=30).json()["data"] for t in data: gp = t["gamesPlayed"] sf = t["shotsForPerGame"] * gp sa = t["shotsAgainstPerGame"] * gp frames.append({ "team": RENAME.get(t["teamFullName"], t["teamFullName"]), "season": label, "gp": gp, "points": t["points"], "gf": t["goalsFor"], "ga": t["goalsAgainst"], "shots_for": round(sf), "shots_against": round(sa), }) return pd.DataFrame(frames) try: df = fetch_seasons() df.to_csv(CSV, index=False) src = "live pull" except Exception as e: # offline fallback: the bundled file is the same table df = pd.read_csv(CSV) src = f"bundled CSV (live pull failed: {type(e).__name__})" df["sh_pct"] = 100 * df["gf"] / df["shots_for"] df["sv_pct"] = 100 * (1 - df["ga"] / df["shots_against"]) df["pdo"] = df["sh_pct"] + df["sv_pct"] with sdt.snippet("pull"): print(f"{len(df)} team-seasons from {src}: " f"{df['season'].nunique()} seasons x {df['team'].nunique()} franchises") print("(Utah's rename is mapped: Hockey Club and Mammoth are one franchise.)\n") sdt.show_df(df[df.season == "2025-26"][["team", "points", "gf", "shots_for", "sh_pct", "sv_pct", "pdo"]] .sort_values("pdo", ascending=False), n=5) cur = df[df.season == "2025-26"].set_index("team") prev = df[df.season == "2024-25"].set_index("team") with sdt.snippet("identity"): for label, d in [("2024-25", prev), ("2025-26", cur)]: lg_sh = 100 * d["gf"].sum() / d["shots_for"].sum() lg_sv = 100 * (1 - d["ga"].sum() / d["shots_against"].sum()) print(f"{label}: league SH% {lg_sh:.2f} + league SV% {lg_sv:.2f} = {lg_sh + lg_sv:.2f}") print("\nEvery goal scored is a goal allowed and every shot taken is a shot faced,") print("so the league's SH% and SV% are the same fraction seen from both benches.") print("PDO does not hover near 100 by tendency - it averages 100 by arithmetic.") with sdt.snippet("pdo"): t5 = cur.nlargest(5, "pdo"); b5 = cur.nsmallest(5, "pdo") print("2025-26 PDO leaders and laggards (all situations, team level):\n") for name, r in pd.concat([t5, b5]).iterrows(): print(f" {name:<24} PDO {r.pdo:6.1f} SH% {r.sh_pct:5.2f} SV% {r.sv_pct:5.2f} pts {int(r.points):3d}") print(f"\nspread: mean {cur.pdo.mean():.1f}, sd {cur.pdo.std(ddof=0):.2f}, " f"range {cur.pdo.min():.1f} to {cur.pdo.max():.1f}") r_now = np.corrcoef(cur.pdo, cur.points)[0, 1] print(f"same-season r(PDO, points) = {r_now:.2f} - the standings are soaked in it") both = cur.join(prev, lsuffix="_26", rsuffix="_25", how="inner") with sdt.snippet("persist"): pairs = [("pdo_25", "pdo_26", "PDO"), ("points_25", "points_26", "points"), ("sh_pct_25", "sh_pct_26", "SH% (shooting half)"), ("sv_pct_25", "sv_pct_26", "SV% (save half)")] print(f"Year-over-year persistence, {len(both)} franchises, 2024-25 -> 2025-26:\n") for a, b, label in pairs: r = np.corrcoef(both[a], both[b])[0, 1] print(f" r({label:<20}) = {r:5.2f}") print("\nThe half of PDO everyone credits to goaltending is the half that vanishes.") with sdt.snippet("flags"): top5 = both.nlargest(5, "pdo_25"); bot5 = both.nsmallest(5, "pdo_25") print("2024-25's five highest-PDO teams, one season later:\n") for name, r in top5.iterrows(): print(f" {name:<24} PDO {r.pdo_25:6.1f} -> pts {int(r.points_25):3d} -> {int(r.points_26):3d} ({int(r.points_26 - r.points_25):+d})") print("\n2024-25's five lowest-PDO teams, one season later:\n") for name, r in bot5.iterrows(): print(f" {name:<24} PDO {r.pdo_25:6.1f} -> pts {int(r.points_25):3d} -> {int(r.points_26):3d} ({int(r.points_26 - r.points_25):+d})") up = int((bot5.points_26 > bot5.points_25).sum()) dn = int((top5.points_26 < top5.points_25).sum()) print(f"\nscoreboard: {dn} of 5 high-PDO teams fell, {up} of 5 low-PDO teams improved.") # ---- the exhibit ------------------------------------------------------------------ HOCKEY = sdt.sport_color("hockey") fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10.6, 4.8)) ax1.scatter(cur.pdo, cur.points, s=42, color=HOCKEY, alpha=0.85, zorder=3) r_now = np.corrcoef(cur.pdo, cur.points)[0, 1] for name in [cur.pdo.idxmax(), cur.pdo.idxmin()]: r = cur.loc[name] ax1.annotate(name.split()[-1], (r.pdo, r.points), textcoords="offset points", xytext=(6, -3), fontsize=8) ax1.axvline(100, color="#C2B7A1", lw=1) ax1.set_xlabel("PDO, 2025-26") ax1.set_ylabel("standings points, 2025-26") ax1.set_title(f"Same season: the standings are soaked in PDO (r = {r_now:.2f})") r_sv = np.corrcoef(both.sv_pct_25, both.sv_pct_26)[0, 1] r_sh = np.corrcoef(both.sh_pct_25, both.sh_pct_26)[0, 1] ax2.scatter(both.sv_pct_25, both.sv_pct_26, s=42, color=HOCKEY, alpha=0.85, zorder=3) lims = [both.sv_pct_25.min() - 0.2, both.sv_pct_25.max() + 0.2] ax2.plot(lims, lims, color="#C2B7A1", lw=1, ls="--") ax2.set_xlabel("team SV%, 2024-25") ax2.set_ylabel("team SV%, 2025-26") ax2.set_title(f"Next season: the save half barely carries over\n(r = {r_sv:.2f}; the shooting half manages {r_sh:.2f})") fig.tight_layout() sdt.save_fig(fig, "pdo_scatter", source="NHL public stats API (api.nhle.com), 2024-25 and 2025-26 regular seasons", asof="August 2026") print("\nPDO is not pure luck - it is a talent signal wrapped in a much louder noise signal.")