Chris Parmer — home

NFL Win-probability chart

Broadcast Sports

Example from the compendium of canonical charts

Broadcast Sports — NFL Win-probability chart

Python Code

"""Broadcast Sports — NFL Win-probability chart."""


import numpy as np
import pandas as pd
import plotly.graph_objects as go
import io, requests, warnings


URL = "https://github.com/nflverse/nflverse-data/releases/download/pbp/play_by_play_2023.csv.gz"


def generate():
    warnings.filterwarnings("ignore")
    df = None
    try:
        requests.packages.urllib3.disable_warnings()
        r = requests.get(URL, verify=False, timeout=120, stream=True)
        r.raise_for_status()
        content = r.content
        df_full = pd.read_csv(io.BytesIO(content), compression="gzip",
                              usecols=["game_id", "home_wp", "game_seconds_remaining"],
                              low_memory=False)
        df_full = df_full.dropna(subset=["home_wp", "game_seconds_remaining"])

        def crossings(grp):
            wp = grp["home_wp"].values
            return int(np.sum(np.diff(np.sign(wp - 0.5)) != 0))

        counts = df_full.groupby("game_id").apply(crossings)
        best_game = counts.idxmax()
        df = df_full[df_full["game_id"] == best_game].copy()
        df["elapsed"] = 3600 - df["game_seconds_remaining"]
        df = df.sort_values("elapsed")
        print(f"Using game {best_game}, {len(df)} plays, {crossings(df)} crossings")
    except Exception as e:
        print(f"Download failed ({e}), using synthetic win probability")

    if df is None:
        RNG = np.random.default_rng(99)
        n = 180
        elapsed = np.linspace(0, 3600, n)
        wp = [0.5]
        for i in range(1, n):
            step = RNG.normal(0, 0.025)
            new = wp[-1] + step
            if elapsed[i] > 3000:
                new = wp[-1] + step * 0.3
            wp.append(float(np.clip(new, 0.02, 0.98)))
        wp = np.array(wp)
        df = pd.DataFrame({"elapsed": elapsed, "home_wp": wp})

    fig = go.Figure()

    fig.add_hrect(y0=0.5, y1=1.0, fillcolor="rgba(132,94,238,0.07)", line_width=0)
    fig.add_hrect(y0=0.0, y1=0.5, fillcolor="rgba(85,182,133,0.07)", line_width=0)

    fig.add_trace(go.Scatter(
        x=df["elapsed"],
        y=df["home_wp"],
        mode="lines",
        line=dict(color=VIOLET, width=2.5),
        showlegend=False,
        hovertemplate="Elapsed: %{x:.0f}s<br>Win prob: %{y:.1%}<extra></extra>",
    ))

    fig.add_hline(y=0.5, line_dash="dash", line_color=MUTED, line_width=1)

    for sec in [900, 1800, 2700]:
        fig.add_vline(x=sec, line_dash="dot", line_color=MUTED, line_width=1)

    fig.update_layout(
        xaxis=dict(
            title="",
            tickvals=[0, 900, 1800, 2700, 3600],
            ticktext=["Start", "Q2", "Half", "Q4", "End"],
            range=[0, 3700],
            showgrid=False,
        ),
        yaxis=dict(
            title="Home Win Probability",
            range=[0, 1],
            tickformat=".0%",
        ),
        showlegend=False,
        margin=dict(t=20, b=50, l=70, r=40),
    )

    return fig


if __name__ == "__main__":
    generate()

Made with Plotly