Skip to content

Commit 79e8bb0

Browse files
feat(plotnine): implement heatmap-cohort-retention (#10309)
## Implementation: `heatmap-cohort-retention` - python/plotnine Implements the **python/plotnine** version of `heatmap-cohort-retention`. **File:** `plots/heatmap-cohort-retention/implementations/python/plotnine.py` **Parent Issue:** #4570 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/32056959727)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 9a175e1 commit 79e8bb0

2 files changed

Lines changed: 187 additions & 131 deletions

File tree

Lines changed: 94 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,44 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
heatmap-cohort-retention: Cohort Retention Heatmap
3-
Library: plotnine 0.15.3 | Python 3.14.3
4-
Quality: 90/100 | Created: 2026-03-16
3+
Library: plotnine 0.15.8 | Python 3.13.15
4+
Quality: 95/100 | Updated: 2026-08-17
55
"""
66

7+
import os
8+
79
import numpy as np
810
import pandas as pd
11+
from matplotlib.patches import FancyBboxPatch
912
from plotnine import (
1013
aes,
11-
annotate,
1214
element_blank,
1315
element_rect,
1416
element_text,
1517
geom_text,
1618
geom_tile,
1719
ggplot,
1820
labs,
19-
scale_color_identity,
20-
scale_fill_gradientn,
21+
scale_fill_gradient,
2122
scale_x_continuous,
2223
scale_y_discrete,
2324
theme,
2425
theme_minimal,
2526
)
2627

2728

29+
# Theme-adaptive chrome
30+
THEME = os.getenv("ANYPLOT_THEME", "light")
31+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
32+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
33+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
34+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
35+
RULE = (26 / 255, 26 / 255, 23 / 255, 0.15) if THEME == "light" else (240 / 255, 239 / 255, 232 / 255, 0.15)
36+
37+
# Imprint sequential colormap (brand green -> blue) for single-polarity continuous data
38+
# Green (brand) reads as "good" -> high retention; blue anchors low retention
39+
SEQ_HIGH_RETENTION = "#009E73"
40+
SEQ_LOW_RETENTION = "#4467A3"
41+
2842
# Data
2943
np.random.seed(42)
3044
cohorts = [
@@ -41,6 +55,8 @@
4155
]
4256
n_cohorts = len(cohorts)
4357
cohort_sizes = [1200, 1350, 980, 1100, 1450, 1280, 1050, 1380, 1150, 1020]
58+
# Mar 2024 (index 2) suffered a pricing-change churn spike -> visibly worse retention
59+
churn_event_idx = 2
4460

4561
rows = []
4662
for i, cohort in enumerate(cohorts):
@@ -49,79 +65,105 @@
4965
if period == 0:
5066
retention = 100.0
5167
else:
52-
base_decay = 100 * np.exp(-0.25 * period)
68+
base_decay = 100 * np.exp(-0.22 * period)
5369
noise = np.random.uniform(-3, 3)
54-
trend_bonus = i * 1.5
55-
retention = np.clip(base_decay + noise + trend_bonus, 5, 100)
70+
trend_bonus = i * 2.2 # onboarding steadily improves for later cohorts
71+
churn_penalty = 14 if i == churn_event_idx else 0
72+
retention = np.clip(base_decay + noise + trend_bonus - churn_penalty, 5, 100)
5673
rows.append(
5774
{"cohort": cohort, "period": period, "retention_rate": round(retention, 1), "cohort_size": cohort_sizes[i]}
5875
)
5976

6077
df = pd.DataFrame(rows)
6178

62-
# Create y-axis labels with cohort size
79+
# Y-axis labels carry cohort size; reversed order puts Jan 2024 at the top, Oct 2024 at the bottom
6380
df["cohort_label"] = df.apply(lambda r: f"{r['cohort']} (n={r['cohort_size']:,})", axis=1)
64-
65-
# Preserve ordering
6681
cohort_labels = [f"{c} (n={s:,})" for c, s in zip(cohorts, cohort_sizes, strict=True)]
6782
df["cohort_label"] = pd.Categorical(df["cohort_label"], categories=cohort_labels[::-1], ordered=True)
6883

69-
# Text color: white on dark cells (viridis dark end), dark on light cells
70-
df["text_color"] = df["retention_rate"].apply(lambda v: "#ffffff" if v < 60 else "#1a1a2e")
71-
72-
# Format retention text
7384
df["label"] = df["retention_rate"].apply(lambda v: f"{v:.0f}%")
7485

75-
# Compare earliest vs latest cohort at same period for storytelling
86+
# Compare an early vs. a later cohort at the same period for storytelling
7687
compare_period = 4
77-
earliest = df[(df["cohort"] == "Jan 2024") & (df["period"] == compare_period)]["retention_rate"].values[0]
78-
latest = df[(df["cohort"] == "Jun 2024") & (df["period"] == compare_period)]["retention_rate"].values[0]
79-
improvement = latest - earliest
80-
81-
# Perceptually uniform sequential palette (viridis-inspired: dark purple → teal → yellow)
82-
colors = ["#440154", "#31688e", "#35b779", "#fde725"]
88+
early_val = df[(df["cohort"] == "Jan 2024") & (df["period"] == compare_period)]["retention_rate"].values[0]
89+
later_val = df[(df["cohort"] == "Jun 2024") & (df["period"] == compare_period)]["retention_rate"].values[0]
90+
improvement = later_val - early_val
91+
cohort_trend_pp = 2.2 # per-cohort onboarding bonus baked into the synthetic retention formula above
8392

8493
# Plot
8594
plot = (
8695
ggplot(df, aes(x="period", y="cohort_label", fill="retention_rate"))
87-
+ geom_tile(color="#f8f9fa", size=0.6)
88-
+ geom_text(aes(label="label", color="text_color"), size=13, fontweight="bold")
89-
+ scale_fill_gradientn(colors=colors, limits=(0, 100), name="Retention %")
90-
+ scale_color_identity()
91-
+ scale_x_continuous(breaks=range(n_cohorts), labels=[f"M{i}" for i in range(n_cohorts)])
92-
+ scale_y_discrete(expand=(0.05, 0))
93-
+ annotate(
94-
"text",
95-
x=n_cohorts - 2,
96-
y=3,
97-
label=f"Month {compare_period} retention improved\n+{improvement:.0f}pp from Jan→Jun 2024",
98-
size=11,
99-
color="#2d2d2d",
100-
ha="center",
101-
fontweight="bold",
102-
)
96+
+ geom_tile(color=PAGE_BG, size=0.8)
97+
+ geom_text(aes(label="label"), size=3.1, color="#FFFFFF", fontweight="bold")
98+
+ scale_fill_gradient(low=SEQ_LOW_RETENTION, high=SEQ_HIGH_RETENTION, limits=(0, 100), name="Retention %")
99+
+ scale_x_continuous(breaks=range(n_cohorts), labels=[f"Month {i}" for i in range(n_cohorts)])
100+
+ scale_y_discrete(expand=(0.06, 0))
103101
+ labs(
104102
x="Months Since Signup",
105103
y="",
106-
title="heatmap-cohort-retention · plotnine · pyplots.ai",
107-
subtitle="Monthly cohort retention — newer cohorts retain significantly better over time",
104+
title="heatmap-cohort-retention · python · plotnine · anyplot.ai",
105+
subtitle="Monthly cohort retention — newer cohorts retain better; Mar 2024 shows a pricing-change churn spike",
108106
)
109107
+ theme_minimal()
110108
+ theme(
111-
figure_size=(16, 9),
112-
plot_title=element_text(size=26, ha="center", weight="bold", color="#0d1b2a"),
113-
plot_subtitle=element_text(size=18, ha="center", color="#555555", style="italic"),
114-
axis_title_x=element_text(size=20, color="#333333"),
115-
axis_text_x=element_text(size=16, color="#444444"),
116-
axis_text_y=element_text(size=16, color="#444444"),
117-
legend_title=element_text(size=16, weight="bold"),
118-
legend_text=element_text(size=14),
109+
figure_size=(6, 6),
110+
plot_title=element_text(size=12, ha="center", weight="bold", color=INK),
111+
plot_subtitle=element_text(size=8, ha="center", color=INK_SOFT, style="italic"),
112+
axis_title_x=element_text(size=10, color=INK),
113+
axis_text_x=element_text(size=8, color=INK_SOFT, angle=45, ha="right"),
114+
axis_text_y=element_text(size=8, color=INK_SOFT),
115+
legend_title=element_text(size=9, weight="bold", color=INK),
116+
legend_text=element_text(size=8, color=INK_SOFT),
117+
legend_background=element_rect(fill=ELEVATED_BG, color=None),
119118
panel_grid_major=element_blank(),
120119
panel_grid_minor=element_blank(),
121-
plot_background=element_rect(fill="#fafafa", color="none"),
122-
panel_background=element_rect(fill="#fafafa", color="none"),
120+
panel_border=element_rect(color=RULE, fill=None, size=0.5),
121+
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
122+
panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
123123
)
124124
)
125125

126+
# Render, then drop into the underlying matplotlib Figure/Axes (a capability
127+
# unique to plotnine's matplotlib backend, unlike R ggplot2's grid graphics) to
128+
# draw a rounded-corner callout that fills the empty triangle beneath the data
129+
# and carries two data-backed insights instead of leaving that panel space bare.
130+
fig = plot.draw()
131+
ax = fig.axes[0]
132+
callout_box = FancyBboxPatch(
133+
(3.6, 0.6),
134+
9.6 - 3.6,
135+
4.4 - 0.6,
136+
transform=ax.transData,
137+
boxstyle="round,pad=0,rounding_size=0.25",
138+
facecolor=ELEVATED_BG,
139+
edgecolor=RULE,
140+
linewidth=1.0,
141+
zorder=5,
142+
)
143+
ax.add_patch(callout_box)
144+
ax.text(
145+
6.6,
146+
3.3,
147+
f"Month {compare_period} retention improved\n+{improvement:.0f}pp from Jan → Jun 2024",
148+
transform=ax.transData,
149+
ha="center",
150+
va="center",
151+
fontsize=9,
152+
color=INK,
153+
fontweight="bold",
154+
zorder=6,
155+
)
156+
ax.text(
157+
6.6,
158+
1.7,
159+
f"Each newer cohort trends ~+{cohort_trend_pp:.1f}pp per\nMonth vs. the prior cohort (onboarding gains)",
160+
transform=ax.transData,
161+
ha="center",
162+
va="center",
163+
fontsize=7.5,
164+
color=INK_SOFT,
165+
zorder=6,
166+
)
167+
126168
# Save
127-
plot.save("plot.png", dpi=300, width=16, height=9)
169+
fig.savefig(f"plot-{THEME}.png", dpi=400)

0 commit comments

Comments
 (0)