Win Probability Chart — Seaborn

A win probability chart shows how each team's likelihood of winning evolves over the course of a game. The line starts near 50% and fluctuates based on scoring events, ultimately reaching 100% or 0% at game end. The area above and below the 50% baseline is filled with team colors to convey momentum at a glance. This visualization is widely used across major sports for post-game analysis and live broadcasting.

Win Probability Chart rendered with Seaborn

Python source (Seaborn)

""" anyplot.ai
line-win-probability: Win Probability Chart
Library: seaborn 0.13.2 | Python 3.13.14
Quality: 91/100 | Updated: 2026-06-21
"""

import os

import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — semantic: green=Home (winning/positive), matte red=Away (opponent/loss)
HOME_COLOR = "#009E73"  # Imprint position 1 — brand green
AWAY_COLOR = "#AE3030"  # Imprint position 5 — matte red (semantic: loss / away team)

sns.set_theme(
    style="ticks",
    rc={
        "figure.facecolor": PAGE_BG,
        "axes.facecolor": PAGE_BG,
        "axes.edgecolor": INK_SOFT,
        "axes.labelcolor": INK,
        "text.color": INK,
        "xtick.color": INK_SOFT,
        "ytick.color": INK_SOFT,
        "grid.color": INK,
        "grid.alpha": 0.15,
        "legend.facecolor": ELEVATED_BG,
        "legend.edgecolor": INK_SOFT,
        "font.family": "sans-serif",
    },
)

# Data — build multiple Monte Carlo runs so seaborn's native errorbar can show model uncertainty
plays = np.arange(0, 121)

scoring_events = {
    8: 0.07,
    22: -0.15,
    35: 0.18,
    48: 0.10,
    55: -0.08,
    65: 0.16,
    78: -0.14,
    85: 0.09,
    95: -0.20,
    105: 0.22,
    115: 0.08,
}

runs = []
for seed in range(42, 62):  # 20 simulations for stable CI band
    rng = np.random.default_rng(seed)
    wp = np.full(len(plays), 0.50)
    for i in range(1, len(plays)):
        noise = rng.normal(0, 0.015)
        shift = scoring_events.get(i, 0.0)
        wp[i] = np.clip(wp[i - 1] + shift + noise, 0.02, 0.98)
    wp[-1] = 0.95  # game ends with home win
    runs.append(pd.DataFrame({"play": plays, "win_probability": wp, "run": seed}))

df_sim = pd.concat(runs, ignore_index=True)

# Mean line for fills and annotation anchors
win_prob_mean = df_sim.groupby("play")["win_probability"].mean().values

# Plot — landscape 3200×1800 px (figsize × dpi, no bbox_inches)
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400)
fig.subplots_adjust(top=0.86)  # reserve headroom for title + subtitle
ax.set_facecolor(PAGE_BG)

# Seaborn lineplot with native errorbar — uses seaborn's statistical aggregation across 20 runs
# to display the mean win-probability line with a ±1 SD uncertainty band
sns.lineplot(
    data=df_sim,
    x="play",
    y="win_probability",
    color=HOME_COLOR,
    linewidth=2.5,
    errorbar="sd",
    err_kws={"alpha": 0.07, "linewidth": 0},
    ax=ax,
)

# Fills between mean line and 50% reference
ax.fill_between(plays, win_prob_mean, 0.5, where=(win_prob_mean >= 0.5), color=HOME_COLOR, alpha=0.14, interpolate=True)
ax.fill_between(plays, win_prob_mean, 0.5, where=(win_prob_mean < 0.5), color=AWAY_COLOR, alpha=0.14, interpolate=True)

# 50% reference line
ax.axhline(y=0.5, color=INK_SOFT, linewidth=0.8, linestyle="--", alpha=0.45)

# Key event annotations — scored on the mean trajectory
key_events = {
    22: ("TD Away\n7–3", AWAY_COLOR),
    35: ("TD Home\n10–7", HOME_COLOR),
    65: ("TD Home\n20–14", HOME_COLOR),
    95: ("TD Away\n23–27", AWAY_COLOR),
    105: ("TD Home\n30–27", HOME_COLOR),
}
annotation_offsets = {22: (-9, 0.13), 35: (2, -0.13), 65: (9, 0.13), 95: (-12, 0.13), 105: (2, -0.15)}

for play_num, (label, color) in key_events.items():
    y_val = win_prob_mean[play_num]
    x_off, y_off = annotation_offsets[play_num]
    ax.annotate(
        label,
        xy=(play_num, y_val),
        xytext=(play_num + x_off, y_val + y_off),
        fontsize=7,
        fontweight="bold",
        color=color,
        ha="center",
        va="center",
        arrowprops={"arrowstyle": "->", "color": color, "lw": 1.0, "connectionstyle": "arc3,rad=0.1"},
    )

# Event marker dots via seaborn scatter
sns.scatterplot(
    x=list(key_events),
    y=[win_prob_mean[p] for p in key_events],
    color=[key_events[p][1] for p in key_events],
    s=55,
    zorder=5,
    edgecolor=PAGE_BG,
    linewidth=1.0,
    ax=ax,
    legend=False,
)

# Quarter boundary markers — Q4 label kept at bottom left of its quarter to avoid top-right clutter
for q, label in [(30, "Q1"), (60, "Q2"), (90, "Q3"), (120, "Q4")]:
    ax.axvline(x=q, color=INK_SOFT, linewidth=0.55, linestyle=":", alpha=0.4)
    ax.text(q - 15, 0.03, label, fontsize=7.5, color=INK_MUTED, ha="center")

# Final score callout — top right, away from Q4 bottom label
ax.text(
    106,
    0.90,
    "Final: Home 30 – Away 27",
    fontsize=7.5,
    ha="left",
    color=INK_SOFT,
    fontweight="semibold",
    fontstyle="italic",
    bbox={"boxstyle": "round,pad=0.28", "facecolor": ELEVATED_BG, "edgecolor": INK_SOFT, "alpha": 0.9},
)

# Axes styling
ax.set_xlabel("Play Number", fontsize=10, color=INK)
ax.set_ylabel("Home Win Probability", fontsize=10, color=INK)
ax.set_title("line-win-probability · python · seaborn · anyplot.ai", fontsize=12, fontweight="medium", pad=4, color=INK)
# subtitle in figure coordinates — sits above the axes title without overlapping
fig.text(
    0.5,
    0.945,
    "NFL Game — Home vs Away  |  Shaded band shows win-probability model uncertainty across simulations",
    ha="center",
    fontsize=7.5,
    color=INK_MUTED,
    fontstyle="italic",
)
ax.tick_params(axis="both", labelsize=8, colors=INK_SOFT)
ax.set_ylim(0, 1)
ax.set_xlim(0, 120)
ax.set_yticks([0, 0.25, 0.5, 0.75, 1.0])
ax.set_yticklabels(["0%", "25%", "50%", "75%", "100%"])

sns.despine(ax=ax)
ax.yaxis.grid(True, alpha=0.15, linewidth=0.6)

# Legend — Home/Away fill patches
home_patch = mpatches.Patch(facecolor=HOME_COLOR, alpha=0.55, label="Home", edgecolor="none")
away_patch = mpatches.Patch(facecolor=AWAY_COLOR, alpha=0.55, label="Away", edgecolor="none")
ax.legend(
    handles=[home_patch, away_patch],
    fontsize=8,
    loc="upper left",
    frameon=True,
    facecolor=ELEVATED_BG,
    edgecolor=INK_SOFT,
    framealpha=0.9,
)

# Save — no bbox_inches so figsize × dpi gives exact 3200×1800 px
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)
plt.close()

Part of Win Probability Chart on anyplot.ai.

Other implementations