{"spec_id":"heatmap-cohort-retention","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nheatmap-cohort-retention: Cohort Retention Heatmap\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import BasicTicker, ColorBar, ColumnDataSource, HoverTool, Label, LinearColorMapper\nfrom bokeh.plotting import figure\nfrom bokeh.transform import transform\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens — Imprint palette 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\"\n\n# Imprint sequential colormap (imprint_seq) — single-polarity continuous retention data\n# Interpolates #009E73 (brand green) → #4467A3 (blue) across 256 stops\nANYPLOT_SEQ256 = [\n    \"#{:02X}{:02X}{:02X}\".format(round(68 * t / 255), round(158 - 55 * t / 255), round(115 + 48 * t / 255))\n    for t in range(256)\n]\n\n# Data: Monthly SaaS signup cohorts with retention tracking\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, size=n_cohorts)\n\n# Generate realistic triangular retention data\nretention = np.full((n_cohorts, n_periods), np.nan)\nfor i in range(n_cohorts):\n    max_periods = n_periods - i\n    retention[i, 0] = 100.0\n    base_decay = np.random.uniform(0.65, 0.80)\n    for j in range(1, max_periods):\n        decay = base_decay + np.random.uniform(-0.05, 0.05)\n        retention[i, j] = retention[i, j - 1] * decay\n        retention[i, j] = max(retention[i, j], 2.0)\n\n# Flatten into ColumnDataSource format\nperiod_labels = [f\"Month {i}\" for i in range(n_periods)]\ny_labels = [f\"{label} (n={size:,})\" for label, size in zip(cohort_labels, cohort_sizes, strict=True)]\n\nx_coords, y_coords, values, text_vals = [], [], [], []\nfor i in range(n_cohorts):\n    for j in range(n_periods):\n        if not np.isnan(retention[i, j]):\n            x_coords.append(period_labels[j])\n            y_coords.append(y_labels[i])\n            values.append(retention[i, j])\n            text_vals.append(f\"{retention[i, j]:.1f}%\")\n\nsource = ColumnDataSource(data={\"x\": x_coords, \"y\": y_coords, \"value\": values, \"text\": text_vals})\n\n# Color mapper using Imprint sequential palette\nmapper = LinearColorMapper(palette=ANYPLOT_SEQ256, low=0, high=100)\n\n# Figure — square 2400×2400 canvas for symmetric heatmap\nTITLE = \"heatmap-cohort-retention · python · bokeh · anyplot.ai\"\nW, H = 2400, 2400\np = figure(\n    width=W,\n    height=H,\n    x_range=period_labels,\n    y_range=list(reversed(y_labels)),\n    title=TITLE,\n    x_axis_location=\"above\",\n    toolbar_location=None,\n    min_border_left=290,\n    min_border_right=130,\n    min_border_top=230,\n    min_border_bottom=90,\n)\n\n# Heatmap rectangles with Imprint sequential fill\nrects = p.rect(\n    x=\"x\",\n    y=\"y\",\n    width=1,\n    height=1,\n    source=source,\n    fill_color=transform(\"value\", mapper),\n    line_color=PAGE_BG,\n    line_width=2,\n)\n\n# HoverTool — Bokeh's distinctive interactive exploration feature\nhover = HoverTool(renderers=[rects], tooltips=[(\"Cohort\", \"@y\"), (\"Period\", \"@x\"), (\"Retention\", \"@text\")])\np.add_tools(hover)\n\n# Cell text — white is readable against both Imprint seq endpoints (both mid-dark)\np.text(\n    x=\"x\",\n    y=\"y\",\n    text=\"text\",\n    source=source,\n    text_align=\"center\",\n    text_baseline=\"middle\",\n    text_font_size=\"22pt\",\n    text_color=\"white\",\n    text_font_style=\"bold\",\n)\n\n# Theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\np.title.text_font_size = \"50pt\"\np.title.align = \"center\"\np.title.text_color = INK\np.title.text_font_style = \"bold\"\n\np.xaxis.axis_label = \"Months Since Signup\"\np.yaxis.axis_label = \"Signup Cohort\"\np.xaxis.axis_label_text_font_size = \"34pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_font_style = \"bold\"\np.yaxis.axis_label_text_font_style = \"bold\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\n\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\np.axis.axis_line_color = None\np.axis.major_tick_line_color = None\np.axis.minor_tick_line_color = None\np.grid.grid_line_color = None\n\n# Insight annotation — Month 3 retention variance across cohorts\nmonth3_retentions = {y_labels[i]: retention[i, 3] for i in range(n_cohorts) if not np.isnan(retention[i, 3])}\nbest_val = month3_retentions[max(month3_retentions, key=month3_retentions.get)]\nworst_val = month3_retentions[min(month3_retentions, key=month3_retentions.get)]\n\np.add_layout(\n    Label(\n        x=1000,\n        y=400,\n        x_units=\"screen\",\n        y_units=\"screen\",\n        text=f\"Month 3 retention: {worst_val:.0f}%–{best_val:.0f}% across cohorts\",\n        text_font_size=\"24pt\",\n        text_color=INK_MUTED,\n        text_font_style=\"italic\",\n    )\n)\n\n# Color bar with theme-adaptive styling\ncolor_bar = ColorBar(\n    color_mapper=mapper,\n    ticker=BasicTicker(desired_num_ticks=6),\n    label_standoff=16,\n    major_label_text_font_size=\"28pt\",\n    major_label_text_color=INK_SOFT,\n    title=\"Retention %\",\n    title_text_font_size=\"30pt\",\n    title_text_font_style=\"bold\",\n    title_text_color=INK,\n    title_standoff=20,\n    width=50,\n    location=(0, 0),\n    bar_line_color=None,\n    border_line_color=None,\n    background_fill_color=PAGE_BG,\n)\np.add_layout(color_bar, \"right\")\n\n# Save HTML (interactive artifact) then screenshot with headless Chrome\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\n# Use CDP to force exact viewport dimensions (avoids outer-window-vs-viewport discrepancy)\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}