Skip to content

Commit d8bf434

Browse files
feat(plotnine): implement line-win-probability (#5098)
## Implementation: `line-win-probability` - plotnine Implements the **plotnine** version of `line-win-probability`. **File:** `plots/line-win-probability/implementations/plotnine.py` **Parent Issue:** #4418 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23342814581)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 0a2ad40 commit d8bf434

2 files changed

Lines changed: 410 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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

Comments
 (0)