|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import numpy as np |
8 | 10 | import pandas as pd |
| 11 | +from matplotlib.patches import FancyBboxPatch |
9 | 12 | from plotnine import ( |
10 | 13 | aes, |
11 | | - annotate, |
12 | 14 | element_blank, |
13 | 15 | element_rect, |
14 | 16 | element_text, |
15 | 17 | geom_text, |
16 | 18 | geom_tile, |
17 | 19 | ggplot, |
18 | 20 | labs, |
19 | | - scale_color_identity, |
20 | | - scale_fill_gradientn, |
| 21 | + scale_fill_gradient, |
21 | 22 | scale_x_continuous, |
22 | 23 | scale_y_discrete, |
23 | 24 | theme, |
24 | 25 | theme_minimal, |
25 | 26 | ) |
26 | 27 |
|
27 | 28 |
|
| 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 | + |
28 | 42 | # Data |
29 | 43 | np.random.seed(42) |
30 | 44 | cohorts = [ |
|
41 | 55 | ] |
42 | 56 | n_cohorts = len(cohorts) |
43 | 57 | 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 |
44 | 60 |
|
45 | 61 | rows = [] |
46 | 62 | for i, cohort in enumerate(cohorts): |
|
49 | 65 | if period == 0: |
50 | 66 | retention = 100.0 |
51 | 67 | else: |
52 | | - base_decay = 100 * np.exp(-0.25 * period) |
| 68 | + base_decay = 100 * np.exp(-0.22 * period) |
53 | 69 | 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) |
56 | 73 | rows.append( |
57 | 74 | {"cohort": cohort, "period": period, "retention_rate": round(retention, 1), "cohort_size": cohort_sizes[i]} |
58 | 75 | ) |
59 | 76 |
|
60 | 77 | df = pd.DataFrame(rows) |
61 | 78 |
|
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 |
63 | 80 | df["cohort_label"] = df.apply(lambda r: f"{r['cohort']} (n={r['cohort_size']:,})", axis=1) |
64 | | - |
65 | | -# Preserve ordering |
66 | 81 | cohort_labels = [f"{c} (n={s:,})" for c, s in zip(cohorts, cohort_sizes, strict=True)] |
67 | 82 | df["cohort_label"] = pd.Categorical(df["cohort_label"], categories=cohort_labels[::-1], ordered=True) |
68 | 83 |
|
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 |
73 | 84 | df["label"] = df["retention_rate"].apply(lambda v: f"{v:.0f}%") |
74 | 85 |
|
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 |
76 | 87 | 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 |
83 | 92 |
|
84 | 93 | # Plot |
85 | 94 | plot = ( |
86 | 95 | 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)) |
103 | 101 | + labs( |
104 | 102 | x="Months Since Signup", |
105 | 103 | 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", |
108 | 106 | ) |
109 | 107 | + theme_minimal() |
110 | 108 | + 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), |
119 | 118 | panel_grid_major=element_blank(), |
120 | 119 | 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), |
123 | 123 | ) |
124 | 124 | ) |
125 | 125 |
|
| 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 | + |
126 | 168 | # Save |
127 | | -plot.save("plot.png", dpi=300, width=16, height=9) |
| 169 | +fig.savefig(f"plot-{THEME}.png", dpi=400) |
0 commit comments