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."""
from pathlib import Path

# ── Palette + theme (matching the Plotly Studio gallery these charts ship in) ──
VIOLET, TEAL, GREEN, PINK, ORANGE = "#845EEE", "#52B3D0", "#55B685", "#DA5597", "#E9A23B"
PRIMARY, SECONDARY = VIOLET, TEAL
COLORWAY = [VIOLET, TEAL, GREEN, PINK, ORANGE]
BG, TEXT, GRID, MUTED = "#ffffff", "#1c2024", "#d9d9e0", "#60646c"
FONT = "Inter, -apple-system, BlinkMacSystemFont, sans-serif"
COLORSCALE = [[0, "rgba(132, 94, 238, 0.05)"], [1, "rgba(132, 94, 238, 0.9)"]]


def apply_theme(fig):
    """Light gallery theme: white background, Inter font, soft gridlines."""
    fig.update_layout(
        paper_bgcolor=BG, plot_bgcolor=BG, colorway=COLORWAY,
        font=dict(family=FONT, color=TEXT, size=12),
        legend=dict(font=dict(color=TEXT)),
        hoverlabel=dict(bgcolor="#f0f0f3", font=dict(color=TEXT, family=FONT), bordercolor=GRID),
    )
    fig.update_xaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
    fig.update_yaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)


def fetch_csv(url, **kwargs):
    import io
    import pandas as pd
    import requests
    r = requests.get(url, timeout=60)
    r.raise_for_status()
    return pd.read_csv(io.StringIO(r.text), **kwargs)


def fetch_json(url):
    import requests
    r = requests.get(url, timeout=60)
    r.raise_for_status()
    return r.json()

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),
    )

    apply_theme(fig)
    return fig


fig = generate()
fig.show()

Made with Plotly