{"spec_id":"heatmap-cohort-retention","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nheatmap-cohort-retention: Cohort Retention Heatmap\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\n\n\nLetsPlot.setup_html()\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nANYPLOT_AMBER = \"#DDCC77\"  # caution/attention accent (outside categorical pool)\n\n# Data — monthly SaaS cohort retention over 10 months\nnp.random.seed(42)\ncohort_labels = [\n    \"Jan 2024\",\n    \"Feb 2024\",\n    \"Mar 2024\",\n    \"Apr 2024\",\n    \"May 2024\",\n    \"Jun 2024\",\n    \"Jul 2024\",\n    \"Aug 2024\",\n    \"Sep 2024\",\n    \"Oct 2024\",\n]\nn_cohorts = len(cohort_labels)\nn_periods = 10\ncohort_sizes = np.random.randint(800, 2500, n_cohorts)\n\n# Distinct per-cohort profiles for visible variation across rows\ncohort_profiles = [\n    np.array([100.0, 68, 55, 48, 43, 39, 36, 34, 32, 31]),  # Jan - strong\n    np.array([100.0, 58, 42, 34, 29, 25, 22, 20, 19, 18]),  # Feb - weak\n    np.array([100.0, 72, 60, 52, 46, 42, 39, 37, 35, 34]),  # Mar - best\n    np.array([100.0, 55, 38, 30, 25, 22, 20, 18, 17, 16]),  # Apr - poor\n    np.array([100.0, 65, 50, 42, 37, 33, 30, 28, 26, 25]),  # May - average\n    np.array([100.0, 60, 45, 36, 31, 27, 24, 22, 21, 20]),  # Jun - below avg\n    np.array([100.0, 70, 56, 47, 41, 37, 34, 32, 30, 29]),  # Jul - improving\n    np.array([100.0, 50, 35, 27, 23, 20, 18, 16, 15, 14]),  # Aug - worst\n    np.array([100.0, 66, 52, 44, 38, 34, 31, 29, 27, 26]),  # Sep - recovery\n    np.array([100.0, 63, 48, 40, 35, 31, 28, 26, 24, 23]),  # Oct - steady\n]\n\n# Triangular retention matrix — earlier cohorts have more observed periods\nrows = []\nfor i in range(n_cohorts):\n    available_periods = n_periods - i\n    prev_retention = 100.0\n    for j in range(available_periods):\n        if j == 0:\n            retention = 100.0\n        else:\n            noise = np.random.uniform(-1.5, 1.5)\n            retention = np.clip(cohort_profiles[i][j] + noise, 5, 100)\n            retention = min(retention, prev_retention - 0.5)\n        prev_retention = retention\n        rows.append(\n            {\n                \"cohort\": f\"{cohort_labels[i]} (n={cohort_sizes[i]:,})\",\n                \"period\": f\"Month {j}\",\n                \"period_num\": j,\n                \"retention\": round(retention, 1),\n            }\n        )\n\ndf = pd.DataFrame(rows)\n\n# Categorical ordering for correct axis display (newest cohort at top)\ncohort_order = [f\"{c} (n={s:,})\" for c, s in zip(cohort_labels, cohort_sizes, strict=False)]\nperiod_order = [f\"Month {j}\" for j in range(n_periods)]\ndf[\"cohort\"] = pd.Categorical(df[\"cohort\"], categories=cohort_order[::-1], ordered=True)\ndf[\"period\"] = pd.Categorical(df[\"period\"], categories=period_order, ordered=True)\n\n# Cell labels with adaptive text contrast\n# imprint_seq: low=#009E73 (green) → high=#4467A3 (blue)\n# Both are mid-dark; near-white text suits high-retention cells, INK suits low-retention\ndf[\"label\"] = df[\"retention\"].apply(lambda v: f\"{v:.0f}%\")\ndf[\"use_dark_text\"] = df[\"retention\"] < 50\ndf_dark_text = df[df[\"use_dark_text\"]].copy()\ndf_light_text = df[~df[\"use_dark_text\"]].copy()\n\n# Month 1 subset — highlight critical first-month churn across all cohorts\ndf_drop = df[df[\"period_num\"] == 1].copy()\n\n# Tooltips\ntile_tooltips = (\n    layer_tooltips().format(\"retention\", \".1f\").line(\"@cohort\").line(\"@period | Retention: @retention%\").min_width(220)\n)\n\n# Title: 57 chars; square canvas (600px base) is narrower than landscape (800px),\n# so scale the 16px baseline by 600/800 to avoid overflow on the right edge\ntitle = \"heatmap-cohort-retention · python · letsplot · anyplot.ai\"\nn = len(title)\nratio = 67 / n if n > 67 else 1.0\ntitle_fontsize = max(round(16 * ratio * (600 / 800)), 11)\n\n# Theme-adaptive chrome — standard scale-based sizes per default-style-guide.md\nanyplot_theme = theme(\n    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    panel_background=element_rect(fill=PAGE_BG),\n    panel_grid=element_blank(),\n    plot_title=element_text(size=title_fontsize, color=INK, face=\"bold\"),\n    plot_subtitle=element_text(size=10, color=INK_MUTED, face=\"italic\"),\n    axis_title=element_text(size=12, color=INK),\n    axis_text=element_text(size=10, color=INK_SOFT),\n    legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    legend_text=element_text(size=10, color=INK_SOFT),\n    legend_title=element_text(size=12, color=INK, face=\"bold\"),\n)\n\n# Plot\nplot = (\n    ggplot(df, aes(x=\"period\", y=\"cohort\", fill=\"retention\"))\n    + geom_tile(tooltips=tile_tooltips, color=PAGE_BG, size=0.3, width=0.98, height=0.98)\n    # Orange borders highlight the critical Month 1 churn drop across all cohorts\n    + geom_tile(\n        aes(x=\"period\", y=\"cohort\"),\n        data=df_drop,\n        fill=\"rgba(0,0,0,0)\",\n        color=ANYPLOT_AMBER,\n        size=2.8,\n        width=0.98,\n        height=0.98,\n        tooltips=\"none\",\n    )\n    + geom_text(\n        aes(x=\"period\", y=\"cohort\", label=\"label\"), data=df_light_text, color=\"#F0EFE8\", size=4, fontface=\"bold\"\n    )\n    + geom_text(aes(x=\"period\", y=\"cohort\", label=\"label\"), data=df_dark_text, color=INK, size=4, fontface=\"bold\")\n    # Imprint sequential colormap: green→blue (single-polarity retention scale)\n    + scale_fill_gradient(\n        low=\"#009E73\",\n        high=\"#4467A3\",\n        limits=[0, 100],\n        name=\"Retention %\",\n        breaks=[0, 25, 50, 75, 100],\n        labels=[\"0%\", \"25%\", \"50%\", \"75%\", \"100%\"],\n    )\n    + labs(\n        x=\"Months Since Signup\",\n        y=\"Signup Cohort\",\n        title=title,\n        subtitle=\"Month 1 critical churn highlighted — largest retention drop across all cohorts\",\n    )\n    + theme_minimal()\n    + anyplot_theme\n    + ggsize(600, 600)\n)\n\n# Save — square canvas: ggsize(600, 600) × scale=4 → 2400×2400 px\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}