|
| 1 | +""" pyplots.ai |
| 2 | +line-win-probability: Win Probability Chart |
| 3 | +Library: plotnine 0.15.3 | Python 3.14.3 |
| 4 | +Quality: 92/100 | Created: 2026-03-20 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pandas as pd |
| 9 | +from plotnine import ( |
| 10 | + aes, |
| 11 | + annotate, |
| 12 | + coord_cartesian, |
| 13 | + element_blank, |
| 14 | + element_line, |
| 15 | + element_rect, |
| 16 | + element_text, |
| 17 | + geom_hline, |
| 18 | + geom_line, |
| 19 | + geom_point, |
| 20 | + geom_rect, |
| 21 | + geom_ribbon, |
| 22 | + geom_segment, |
| 23 | + geom_text, |
| 24 | + ggplot, |
| 25 | + labs, |
| 26 | + scale_alpha_identity, |
| 27 | + scale_fill_manual, |
| 28 | + scale_x_continuous, |
| 29 | + scale_y_continuous, |
| 30 | + theme, |
| 31 | + theme_minimal, |
| 32 | +) |
| 33 | + |
| 34 | + |
| 35 | +# Data |
| 36 | +np.random.seed(42) |
| 37 | + |
| 38 | +n_plays = 130 |
| 39 | +plays = np.arange(n_plays) |
| 40 | +win_prob = np.zeros(n_plays) |
| 41 | +win_prob[0] = 0.50 |
| 42 | + |
| 43 | +scoring_plays = { |
| 44 | + 12: ("FG Home", 0.10), |
| 45 | + 28: ("TD Away", -0.18), |
| 46 | + 42: ("TD Home", 0.22), |
| 47 | + 55: ("FG Away", -0.08), |
| 48 | + 68: ("TD Home", 0.15), |
| 49 | + 82: ("TD Away", -0.20), |
| 50 | + 95: ("FG Home", 0.12), |
| 51 | + 110: ("TD Home", 0.16), |
| 52 | + 122: ("FG Away", -0.05), |
| 53 | +} |
| 54 | + |
| 55 | +events = {} |
| 56 | +for i in range(1, n_plays): |
| 57 | + drift = np.random.normal(0, 0.012) |
| 58 | + if i in scoring_plays: |
| 59 | + label, shift = scoring_plays[i] |
| 60 | + win_prob[i] = win_prob[i - 1] + shift + drift |
| 61 | + events[i] = label |
| 62 | + else: |
| 63 | + win_prob[i] = win_prob[i - 1] + drift |
| 64 | + |
| 65 | +win_prob = np.clip(win_prob, 0.04, 0.96) |
| 66 | + |
| 67 | +for i in range(n_plays - 8, n_plays): |
| 68 | + t = (i - (n_plays - 8)) / 7.0 |
| 69 | + win_prob[i] = win_prob[n_plays - 9] * (1 - t) + 0.78 * t |
| 70 | + |
| 71 | +home_fill = np.maximum(win_prob, 0.5) |
| 72 | +away_fill = np.minimum(win_prob, 0.5) |
| 73 | + |
| 74 | +df = pd.DataFrame({"play": plays, "win_prob": win_prob}) |
| 75 | + |
| 76 | +df_home = pd.DataFrame({"play": plays, "ymin": 0.5, "ymax": home_fill, "team": "Eagles (Home)"}) |
| 77 | +df_away = pd.DataFrame({"play": plays, "ymin": away_fill, "ymax": 0.5, "team": "Cowboys (Away)"}) |
| 78 | +df_ribbon = pd.concat([df_home, df_away], ignore_index=True) |
| 79 | + |
| 80 | +event_df = pd.DataFrame( |
| 81 | + {"play": list(events.keys()), "win_prob": [win_prob[p] for p in events.keys()], "label": list(events.values())} |
| 82 | +) |
| 83 | + |
| 84 | +# Smart annotation positioning with staggered offsets to avoid overlap in Q4 |
| 85 | +label_offsets = [] |
| 86 | +for _idx, row in event_df.iterrows(): |
| 87 | + base = 0.06 if row["win_prob"] > 0.5 else -0.06 |
| 88 | + # Extra offset for crowded late-game region |
| 89 | + if row["play"] >= 90: |
| 90 | + base *= 1.5 |
| 91 | + label_offsets.append(row["win_prob"] + base) |
| 92 | +event_df["label_y"] = label_offsets |
| 93 | + |
| 94 | +# Highlight the decisive moment (TD Home at play 110 that sealed the game) |
| 95 | +decisive_play = 110 |
| 96 | +highlight_df = pd.DataFrame({"xmin": [104], "xmax": [116], "ymin": [0.50], "ymax": [0.96], "alpha": [0.06]}) |
| 97 | + |
| 98 | +# Quarter boundary data for geom_segment (plotnine-idiomatic layer composition) |
| 99 | +quarter_df = pd.DataFrame({"x": [32, 65, 97], "ymin": [0.0] * 3, "ymax": [1.0] * 3}) |
| 100 | + |
| 101 | +# Plot |
| 102 | +quarter_breaks = [0, 32, 65, 97, 129] |
| 103 | +quarter_labels = ["Kickoff", "Q2", "Halftime", "Q4", "Final"] |
| 104 | + |
| 105 | +plot = ( |
| 106 | + ggplot() |
| 107 | + # Decisive moment highlight zone |
| 108 | + + geom_rect( |
| 109 | + aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax", alpha="alpha"), |
| 110 | + data=highlight_df, |
| 111 | + fill="#DAA520", |
| 112 | + inherit_aes=False, |
| 113 | + ) |
| 114 | + + scale_alpha_identity() |
| 115 | + # Team-colored area fills |
| 116 | + + geom_ribbon(aes(x="play", ymin="ymin", ymax="ymax", fill="team"), data=df_ribbon, alpha=0.35) |
| 117 | + # 50% reference line |
| 118 | + + geom_hline(yintercept=0.5, color="#888888", size=0.6, linetype="dashed") |
| 119 | + # Quarter boundaries via geom_segment (plotnine-idiomatic vs geom_vline) |
| 120 | + + geom_segment( |
| 121 | + aes(x="x", xend="x", y="ymin", yend="ymax"), |
| 122 | + data=quarter_df, |
| 123 | + color="#cccccc", |
| 124 | + size=0.4, |
| 125 | + linetype="dotted", |
| 126 | + inherit_aes=False, |
| 127 | + ) |
| 128 | + # Win probability trace |
| 129 | + + geom_line(aes(x="play", y="win_prob"), data=df, color="#1a1a1a", size=1.2) |
| 130 | + # Scoring event markers |
| 131 | + + geom_point( |
| 132 | + aes(x="play", y="win_prob"), data=event_df, color="#1a1a1a", size=4, fill="white", stroke=0.8, shape="o" |
| 133 | + ) |
| 134 | + # Event annotations |
| 135 | + + geom_text(aes(x="play", y="label_y", label="label"), data=event_df, size=7, fontweight="bold", color="#333333") |
| 136 | + # Scales |
| 137 | + + scale_fill_manual(values={"Eagles (Home)": "#004C54", "Cowboys (Away)": "#8B1A1A"}) |
| 138 | + + scale_x_continuous(breaks=quarter_breaks, labels=quarter_labels, expand=(0.03, 2)) |
| 139 | + + scale_y_continuous( |
| 140 | + labels=lambda lst: [f"{int(v * 100)}%" for v in lst], limits=(0, 1), breaks=[0, 0.25, 0.5, 0.75, 1.0] |
| 141 | + ) |
| 142 | + + coord_cartesian(xlim=(-2, 134)) |
| 143 | + + labs( |
| 144 | + x="Game Progression", y="Home Win Probability", title="line-win-probability · plotnine · pyplots.ai", fill="" |
| 145 | + ) |
| 146 | + # Final score box - placed bottom-left for balance |
| 147 | + + annotate( |
| 148 | + "label", |
| 149 | + x=10, |
| 150 | + y=0.06, |
| 151 | + label="Final: Eagles 24 – Cowboys 17", |
| 152 | + size=9, |
| 153 | + fill="#f5f5f5", |
| 154 | + color="#333333", |
| 155 | + fontweight="bold", |
| 156 | + label_padding=0.5, |
| 157 | + ) |
| 158 | + # Theme with plotnine-specific element styling |
| 159 | + + theme_minimal() |
| 160 | + + theme( |
| 161 | + figure_size=(16, 9), |
| 162 | + plot_title=element_text(size=24, weight="bold"), |
| 163 | + axis_title_x=element_text(size=18), |
| 164 | + axis_title_y=element_text(size=18), |
| 165 | + axis_text=element_text(size=16, color="#555555"), |
| 166 | + legend_text=element_text(size=15), |
| 167 | + legend_position="top", |
| 168 | + legend_background=element_rect(fill="#fafafa", color="#e0e0e0", size=0.3), |
| 169 | + legend_key_size=18, |
| 170 | + panel_grid_major_x=element_blank(), |
| 171 | + panel_grid_minor=element_blank(), |
| 172 | + panel_grid_major_y=element_line(color="#e8e8e8", size=0.3), |
| 173 | + ) |
| 174 | +) |
| 175 | + |
| 176 | +# Save |
| 177 | +plot.save("plot.png", dpi=300, verbose=False) |
0 commit comments