{"spec_id":"line-win-probability","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nline-win-probability: Win Probability Chart\nLibrary: seaborn 0.13.2 | Python 3.13.14\nQuality: 91/100 | Updated: 2026-06-21\n\"\"\"\n\nimport os\n\nimport matplotlib.patches as mpatches\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — semantic: green=Home (winning/positive), matte red=Away (opponent/loss)\nHOME_COLOR = \"#009E73\"  # Imprint position 1 — brand green\nAWAY_COLOR = \"#AE3030\"  # Imprint position 5 — matte red (semantic: loss / away team)\n\nsns.set_theme(\n    style=\"ticks\",\n    rc={\n        \"figure.facecolor\": PAGE_BG,\n        \"axes.facecolor\": PAGE_BG,\n        \"axes.edgecolor\": INK_SOFT,\n        \"axes.labelcolor\": INK,\n        \"text.color\": INK,\n        \"xtick.color\": INK_SOFT,\n        \"ytick.color\": INK_SOFT,\n        \"grid.color\": INK,\n        \"grid.alpha\": 0.15,\n        \"legend.facecolor\": ELEVATED_BG,\n        \"legend.edgecolor\": INK_SOFT,\n        \"font.family\": \"sans-serif\",\n    },\n)\n\n# Data — build multiple Monte Carlo runs so seaborn's native errorbar can show model uncertainty\nplays = np.arange(0, 121)\n\nscoring_events = {\n    8: 0.07,\n    22: -0.15,\n    35: 0.18,\n    48: 0.10,\n    55: -0.08,\n    65: 0.16,\n    78: -0.14,\n    85: 0.09,\n    95: -0.20,\n    105: 0.22,\n    115: 0.08,\n}\n\nruns = []\nfor seed in range(42, 62):  # 20 simulations for stable CI band\n    rng = np.random.default_rng(seed)\n    wp = np.full(len(plays), 0.50)\n    for i in range(1, len(plays)):\n        noise = rng.normal(0, 0.015)\n        shift = scoring_events.get(i, 0.0)\n        wp[i] = np.clip(wp[i - 1] + shift + noise, 0.02, 0.98)\n    wp[-1] = 0.95  # game ends with home win\n    runs.append(pd.DataFrame({\"play\": plays, \"win_probability\": wp, \"run\": seed}))\n\ndf_sim = pd.concat(runs, ignore_index=True)\n\n# Mean line for fills and annotation anchors\nwin_prob_mean = df_sim.groupby(\"play\")[\"win_probability\"].mean().values\n\n# Plot — landscape 3200×1800 px (figsize × dpi, no bbox_inches)\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400)\nfig.subplots_adjust(top=0.86)  # reserve headroom for title + subtitle\nax.set_facecolor(PAGE_BG)\n\n# Seaborn lineplot with native errorbar — uses seaborn's statistical aggregation across 20 runs\n# to display the mean win-probability line with a ±1 SD uncertainty band\nsns.lineplot(\n    data=df_sim,\n    x=\"play\",\n    y=\"win_probability\",\n    color=HOME_COLOR,\n    linewidth=2.5,\n    errorbar=\"sd\",\n    err_kws={\"alpha\": 0.07, \"linewidth\": 0},\n    ax=ax,\n)\n\n# Fills between mean line and 50% reference\nax.fill_between(plays, win_prob_mean, 0.5, where=(win_prob_mean >= 0.5), color=HOME_COLOR, alpha=0.14, interpolate=True)\nax.fill_between(plays, win_prob_mean, 0.5, where=(win_prob_mean < 0.5), color=AWAY_COLOR, alpha=0.14, interpolate=True)\n\n# 50% reference line\nax.axhline(y=0.5, color=INK_SOFT, linewidth=0.8, linestyle=\"--\", alpha=0.45)\n\n# Key event annotations — scored on the mean trajectory\nkey_events = {\n    22: (\"TD Away\\n7–3\", AWAY_COLOR),\n    35: (\"TD Home\\n10–7\", HOME_COLOR),\n    65: (\"TD Home\\n20–14\", HOME_COLOR),\n    95: (\"TD Away\\n23–27\", AWAY_COLOR),\n    105: (\"TD Home\\n30–27\", HOME_COLOR),\n}\nannotation_offsets = {22: (-9, 0.13), 35: (2, -0.13), 65: (9, 0.13), 95: (-12, 0.13), 105: (2, -0.15)}\n\nfor play_num, (label, color) in key_events.items():\n    y_val = win_prob_mean[play_num]\n    x_off, y_off = annotation_offsets[play_num]\n    ax.annotate(\n        label,\n        xy=(play_num, y_val),\n        xytext=(play_num + x_off, y_val + y_off),\n        fontsize=7,\n        fontweight=\"bold\",\n        color=color,\n        ha=\"center\",\n        va=\"center\",\n        arrowprops={\"arrowstyle\": \"->\", \"color\": color, \"lw\": 1.0, \"connectionstyle\": \"arc3,rad=0.1\"},\n    )\n\n# Event marker dots via seaborn scatter\nsns.scatterplot(\n    x=list(key_events),\n    y=[win_prob_mean[p] for p in key_events],\n    color=[key_events[p][1] for p in key_events],\n    s=55,\n    zorder=5,\n    edgecolor=PAGE_BG,\n    linewidth=1.0,\n    ax=ax,\n    legend=False,\n)\n\n# Quarter boundary markers — Q4 label kept at bottom left of its quarter to avoid top-right clutter\nfor q, label in [(30, \"Q1\"), (60, \"Q2\"), (90, \"Q3\"), (120, \"Q4\")]:\n    ax.axvline(x=q, color=INK_SOFT, linewidth=0.55, linestyle=\":\", alpha=0.4)\n    ax.text(q - 15, 0.03, label, fontsize=7.5, color=INK_MUTED, ha=\"center\")\n\n# Final score callout — top right, away from Q4 bottom label\nax.text(\n    106,\n    0.90,\n    \"Final: Home 30 – Away 27\",\n    fontsize=7.5,\n    ha=\"left\",\n    color=INK_SOFT,\n    fontweight=\"semibold\",\n    fontstyle=\"italic\",\n    bbox={\"boxstyle\": \"round,pad=0.28\", \"facecolor\": ELEVATED_BG, \"edgecolor\": INK_SOFT, \"alpha\": 0.9},\n)\n\n# Axes styling\nax.set_xlabel(\"Play Number\", fontsize=10, color=INK)\nax.set_ylabel(\"Home Win Probability\", fontsize=10, color=INK)\nax.set_title(\"line-win-probability · python · seaborn · anyplot.ai\", fontsize=12, fontweight=\"medium\", pad=4, color=INK)\n# subtitle in figure coordinates — sits above the axes title without overlapping\nfig.text(\n    0.5,\n    0.945,\n    \"NFL Game — Home vs Away  |  Shaded band shows win-probability model uncertainty across simulations\",\n    ha=\"center\",\n    fontsize=7.5,\n    color=INK_MUTED,\n    fontstyle=\"italic\",\n)\nax.tick_params(axis=\"both\", labelsize=8, colors=INK_SOFT)\nax.set_ylim(0, 1)\nax.set_xlim(0, 120)\nax.set_yticks([0, 0.25, 0.5, 0.75, 1.0])\nax.set_yticklabels([\"0%\", \"25%\", \"50%\", \"75%\", \"100%\"])\n\nsns.despine(ax=ax)\nax.yaxis.grid(True, alpha=0.15, linewidth=0.6)\n\n# Legend — Home/Away fill patches\nhome_patch = mpatches.Patch(facecolor=HOME_COLOR, alpha=0.55, label=\"Home\", edgecolor=\"none\")\naway_patch = mpatches.Patch(facecolor=AWAY_COLOR, alpha=0.55, label=\"Away\", edgecolor=\"none\")\nax.legend(\n    handles=[home_patch, away_patch],\n    fontsize=8,\n    loc=\"upper left\",\n    frameon=True,\n    facecolor=ELEVATED_BG,\n    edgecolor=INK_SOFT,\n    framealpha=0.9,\n)\n\n# Save — no bbox_inches so figsize × dpi gives exact 3200×1800 px\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\nplt.close()\n"}