|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | line-retention-cohort: User Retention Curve by Cohort |
3 | 3 | Library: seaborn 0.13.2 | Python 3.14.3 |
4 | 4 | Quality: 87/100 | Created: 2026-03-16 |
|
21 | 21 | decay_rates = [0.18, 0.16, 0.14, 0.12, 0.10] |
22 | 22 | floors = [8, 10, 14, 18, 22] |
23 | 23 |
|
| 24 | +endpoint_values = {} |
| 25 | + |
24 | 26 | for (cohort_label, cohort_size), decay, floor in zip(cohorts.items(), decay_rates, floors, strict=True): |
25 | 27 | retention = 100 * np.exp(-decay * weeks) + floor * (1 - np.exp(-0.3 * weeks)) |
26 | 28 | retention[0] = 100.0 |
27 | 29 | retention = np.clip(retention, 0, 100) |
28 | 30 | noise = np.random.normal(0, 0.8, len(weeks)) |
29 | 31 | noise[0] = 0 |
30 | 32 | retention = np.clip(retention + noise, 0, 100) |
| 33 | + label = f"{cohort_label} (n={cohort_size:,})" |
| 34 | + endpoint_values[label] = retention[-1] |
31 | 35 | for w, r in zip(weeks, retention, strict=True): |
32 | | - records.append({"week": w, "retention": r, "cohort": f"{cohort_label} (n={cohort_size:,})"}) |
| 36 | + records.append({"week": w, "retention": r, "cohort": label}) |
33 | 37 |
|
34 | 38 | df = pd.DataFrame(records) |
35 | 39 |
|
| 40 | +# Custom palette starting with Python Blue |
| 41 | +palette = ["#306998", "#E8922A", "#3A9E78", "#D94F4F", "#8B6DB0"] |
| 42 | + |
36 | 43 | # Plot - use seaborn style, context, and hue-based grouping |
37 | 44 | sns.set_theme( |
38 | 45 | style="whitegrid", |
| 46 | + font="sans-serif", |
39 | 47 | rc={ |
40 | 48 | "axes.spines.top": False, |
41 | 49 | "axes.spines.right": False, |
42 | | - "grid.alpha": 0.15, |
43 | | - "grid.linewidth": 0.8, |
| 50 | + "axes.spines.left": False, |
| 51 | + "grid.alpha": 0.12, |
| 52 | + "grid.linewidth": 0.6, |
44 | 53 | "axes.grid.axis": "y", |
| 54 | + "axes.facecolor": "#FAFAFA", |
| 55 | + "figure.facecolor": "white", |
| 56 | + "font.family": "sans-serif", |
45 | 57 | }, |
46 | 58 | ) |
47 | 59 | sns.set_context("talk", font_scale=1.1) |
48 | 60 |
|
49 | | -palette = sns.color_palette("colorblind", n_colors=5) |
50 | | - |
51 | 61 | fig, ax = plt.subplots(figsize=(16, 9)) |
52 | 62 |
|
53 | 63 | sns.lineplot( |
|
64 | 74 | ax=ax, |
65 | 75 | ) |
66 | 76 |
|
| 77 | +# Progressive emphasis: older cohorts thinner/lighter, newer bolder |
67 | 78 | cohort_labels = df["cohort"].unique() |
68 | 79 | for i, line in enumerate(ax.lines[: len(cohort_labels)]): |
69 | | - weight = 1.5 + i * 0.4 |
| 80 | + weight = 1.5 + i * 0.5 |
70 | 81 | line.set_linewidth(weight) |
71 | 82 | line.set_markersize(5 + i * 1.5) |
72 | | - line.set_alpha(0.5 + i * 0.12) |
73 | | - |
74 | | -ax.axhline(y=20, color="#888888", linestyle="--", linewidth=1.2, alpha=0.5, zorder=1) |
75 | | -ax.text(12.3, 20, "20% target", fontsize=13, color="#888888", va="center", fontstyle="italic") |
| 83 | + line.set_alpha(0.45 + i * 0.13) |
| 84 | + |
| 85 | +# 20% reference line |
| 86 | +ax.axhline(y=20, color="#AAAAAA", linestyle="--", linewidth=1.0, alpha=0.6, zorder=1) |
| 87 | +ax.text(12.3, 15, "20% target", fontsize=14, color="#999999", va="center", fontstyle="italic") |
| 88 | + |
| 89 | +# Endpoint annotations for data storytelling |
| 90 | +sorted_endpoints = sorted(endpoint_values.items(), key=lambda x: list(endpoint_values.keys()).index(x[0])) |
| 91 | +placed_positions = [] |
| 92 | +for i, (_label, val) in enumerate(sorted_endpoints): |
| 93 | + color = palette[i] |
| 94 | + # Avoid overlap with other annotations by nudging |
| 95 | + pos = val |
| 96 | + for prev in placed_positions: |
| 97 | + if abs(pos - prev) < 4: |
| 98 | + pos = prev + 4 if pos >= prev else prev - 4 |
| 99 | + placed_positions.append(pos) |
| 100 | + ax.annotate( |
| 101 | + f"{val:.0f}%", |
| 102 | + xy=(12, val), |
| 103 | + xytext=(12.6, pos), |
| 104 | + fontsize=13, |
| 105 | + fontweight="bold", |
| 106 | + color=color, |
| 107 | + va="center", |
| 108 | + ha="left", |
| 109 | + ) |
76 | 110 |
|
77 | 111 | # Style |
78 | | -ax.set_title("line-retention-cohort · seaborn · pyplots.ai", fontsize=24, fontweight="medium", pad=20) |
79 | | -ax.set_xlabel("Weeks Since Signup", fontsize=20) |
80 | | -ax.set_ylabel("Retained Users (%)", fontsize=20) |
81 | | -ax.tick_params(axis="both", labelsize=16) |
| 112 | +ax.set_title("line-retention-cohort · seaborn · pyplots.ai", fontsize=24, fontweight="bold", pad=24, color="#333333") |
| 113 | +ax.set_xlabel("Weeks Since Signup", fontsize=20, color="#555555", labelpad=12) |
| 114 | +ax.set_ylabel("Retained Users (%)", fontsize=20, color="#555555", labelpad=12) |
| 115 | +ax.tick_params(axis="both", labelsize=16, colors="#666666") |
82 | 116 |
|
83 | | -ax.set_xlim(-0.3, 12.5) |
84 | | -ax.set_ylim(0, 105) |
| 117 | +ax.set_xlim(-0.3, 13.5) |
| 118 | +ax.set_ylim(0, 108) |
85 | 119 | ax.set_xticks(weeks) |
86 | 120 |
|
87 | | -legend = ax.legend(fontsize=14, frameon=False, loc="upper right", title="Signup Cohort", title_fontsize=15) |
| 121 | +# Use sns.despine for seaborn-idiomatic spine removal |
| 122 | +sns.despine(ax=ax, left=True, bottom=False) |
| 123 | + |
| 124 | +legend = ax.legend( |
| 125 | + fontsize=13, |
| 126 | + frameon=True, |
| 127 | + fancybox=True, |
| 128 | + framealpha=0.85, |
| 129 | + edgecolor="#DDDDDD", |
| 130 | + loc="upper right", |
| 131 | + title="Signup Cohort", |
| 132 | + title_fontsize=15, |
| 133 | +) |
88 | 134 | legend.get_title().set_fontweight("semibold") |
| 135 | +legend.get_frame().set_linewidth(0.5) |
89 | 136 |
|
90 | 137 | plt.tight_layout() |
91 | 138 | plt.savefig("plot.png", dpi=300, bbox_inches="tight") |
0 commit comments