{"spec_id":"heatmap-cohort-retention","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nheatmap-cohort-retention: Cohort Retention Heatmap\nLibrary: plotly 6.8.0 | Python 3.13.14\nQuality: 89/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Imprint sequential colormap for continuous retention data (green → blue)\nimprint_seq = [[0.0, \"#009E73\"], [1.0, \"#4467A3\"]]\n\n# Data\nnp.random.seed(42)\n\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]\nnum_cohorts = len(cohort_labels)\nnum_periods = num_cohorts\ncohort_sizes = [1200, 1350, 980, 1100, 1450, 1280, 1050, 1380, 1150, 900]\n\nretention = np.full((num_cohorts, num_periods), np.nan)\nfor i in range(num_cohorts):\n    max_period = num_periods - i\n    retention[i, 0] = 100.0\n    for j in range(1, max_period):\n        base_drop = np.exp(-0.25 * j) * 100\n        noise = np.random.normal(0, 3)\n        trend_bonus = i * 0.8\n        retention[i, j] = np.clip(base_drop + noise + trend_bonus, 5, 100)\n\ny_labels = [f\"{label}  (n={size:,})\" for label, size in zip(cohort_labels, cohort_sizes, strict=False)]\nx_labels = [f\"Month {i}\" for i in range(num_periods)]\n\n# Hover text and cell annotations\nhover_text = []\ncell_annotations = []\nfor i in range(num_cohorts):\n    row_hover = []\n    for j in range(num_periods):\n        if np.isnan(retention[i, j]):\n            row_hover.append(\"\")\n        else:\n            val = retention[i, j]\n            row_hover.append(\n                f\"<b>{cohort_labels[i]}</b> · Month {j}<br>\"\n                f\"Cohort size: {cohort_sizes[i]:,} users<br>\"\n                f\"Retained: <b>{val:.1f}%</b>\"\n            )\n            # Luminance-based contrast: WCAG crossover on imprint_seq is ~88%\n            text_color = \"#F0EFE8\" if val >= 88 else \"#1A1A17\"\n            cell_annotations.append(\n                {\n                    \"x\": x_labels[j],\n                    \"y\": y_labels[i],\n                    \"text\": f\"<b>{val:.0f}%</b>\",\n                    \"showarrow\": False,\n                    \"font\": {\"size\": 9, \"color\": text_color},\n                }\n            )\n    hover_text.append(row_hover)\n\n# Storytelling: identify the cohort with the best Month-3 retention\nm3_entries = [(retention[i, 3], i) for i in range(num_cohorts) if not np.isnan(retention[i, 3])]\nbest_m3_val, best_m3_i = max(m3_entries)\n\n# Plot\nfig = go.Figure(\n    data=go.Heatmap(\n        z=retention,\n        x=x_labels,\n        y=y_labels,\n        showscale=True,\n        hovertext=hover_text,\n        hoverinfo=\"text\",\n        colorscale=imprint_seq,\n        zmin=0,\n        zmax=100,\n        colorbar={\n            \"title\": {\"text\": \"Retention Rate\", \"font\": {\"size\": 12, \"color\": INK}},\n            \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n            \"ticksuffix\": \"%\",\n            \"tickvals\": [0, 20, 40, 60, 80, 100],\n            \"len\": 0.75,\n            \"thickness\": 15,\n            \"outlinewidth\": 0,\n        },\n        xgap=3,\n        ygap=3,\n    )\n)\n\nfor ann in cell_annotations:\n    fig.add_annotation(**ann)\n\n# Storytelling annotation in the empty lower-right triangle (newer cohorts, later months)\nfig.add_annotation(\n    xref=\"paper\",\n    yref=\"paper\",\n    x=0.72,\n    y=0.10,\n    text=f\"★ Best Month-3 cohort<br>{cohort_labels[best_m3_i]}: {best_m3_val:.0f}%\",\n    showarrow=False,\n    font={\"size\": 9, \"color\": INK, \"family\": \"Arial, Helvetica, sans-serif\"},\n    bgcolor=PAGE_BG,\n    bordercolor=INK_SOFT,\n    borderwidth=1,\n    xanchor=\"center\",\n    yanchor=\"middle\",\n    opacity=0.9,\n)\n\ntitle = \"heatmap-cohort-retention · python · plotly · anyplot.ai\"\n\nfig.update_layout(\n    autosize=False,\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"family\": \"Arial, Helvetica, sans-serif\", \"color\": INK},\n    title={\"text\": title, \"font\": {\"size\": 16, \"color\": INK}, \"x\": 0.5, \"xanchor\": \"center\"},\n    xaxis={\n        \"title\": {\"text\": \"Months Since Signup\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"linecolor\": INK_SOFT,\n        \"showgrid\": False,\n    },\n    yaxis={\n        \"title\": {\"text\": \"Signup Cohort\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"autorange\": \"reversed\",\n        \"linecolor\": INK_SOFT,\n        \"showgrid\": False,\n    },\n    margin={\"l\": 155, \"r\": 90, \"t\": 80, \"b\": 70},\n)\n\n# Save — 2400 × 2400 square canvas (heatmap)\nfig.write_image(f\"plot-{THEME}.png\", width=600, height=600, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}