{"spec_id":"heatmap-cohort-retention","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nheatmap-cohort-retention: Cohort Retention Heatmap\nLibrary: seaborn 0.13.2 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\n\nimport matplotlib.colors as mcolors\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\n\n\n# Theme tokens\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\"\n\n# Imprint sequential colormap — blue (low retention) to green (high retention)\nimprint_seq = mcolors.LinearSegmentedColormap.from_list(\"imprint_seq\", [\"#4467A3\", \"#009E73\"])\n\n# Theme-aware seaborn style\nsns.set_theme(\n    style=\"white\",\n    rc={\n        \"figure.facecolor\": PAGE_BG,\n        \"axes.facecolor\": PAGE_BG,\n        \"axes.edgecolor\": INK_SOFT,\n        \"axes.labelcolor\": INK,\n        \"text.color\": INK,\n        \"xtick.color\": INK_SOFT,\n        \"ytick.color\": INK_SOFT,\n    },\n)\n\n# Data — 8 cohorts x 8 periods (triangular shape)\nnp.random.seed(42)\n\ncohort_labels = [\"Jan 2024\", \"Feb 2024\", \"Mar 2024\", \"Apr 2024\", \"May 2024\", \"Jun 2024\", \"Jul 2024\", \"Aug 2024\"]\nn_cohorts = len(cohort_labels)\nn_periods = n_cohorts\ncohort_sizes = np.random.randint(800, 2800, size=n_cohorts)\n\n# Deliberately varied decay rates for distinct cross-cohort comparison\nbase_decays = [0.88, 0.74, 0.82, 0.67, 0.79, 0.86, 0.71, 0.90]\nretention_data = np.full((n_cohorts, n_periods), np.nan)\nfor i in range(n_cohorts):\n    max_periods = n_periods - i\n    retention_data[i, 0] = 100.0\n    for j in range(1, max_periods):\n        prev = retention_data[i, j - 1]\n        noise = np.random.uniform(-0.02, 0.02)\n        decay = min(base_decays[i] + noise, 0.98)\n        retention_data[i, j] = round(prev * decay, 1)\n\nperiod_labels = [f\"Month {i}\" for i in range(n_periods)]\ndf_heatmap = pd.DataFrame(retention_data, index=cohort_labels, columns=period_labels)\n\n# Plot — landscape canvas (3200 x 1800 px); wider cells accommodate annotations\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400)\nfig.set_facecolor(PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\nmask = df_heatmap.isna()\nannot_strings = df_heatmap.map(lambda v: f\"{v:.0f}%\" if not np.isnan(v) else \"\")\n\nsns.heatmap(\n    df_heatmap,\n    mask=mask,\n    annot=annot_strings,\n    fmt=\"\",\n    cmap=imprint_seq,\n    vmin=0,\n    vmax=100,\n    linewidths=2.5,\n    linecolor=PAGE_BG,\n    ax=ax,\n    annot_kws={\"fontsize\": 12, \"fontweight\": \"bold\", \"color\": \"#F0EFE8\"},\n    cbar_kws={\"label\": \"Retention %\", \"shrink\": 0.7, \"aspect\": 20, \"pad\": 0.02},\n    square=False,\n)\n\n# Style\ny_labels = [f\"{label}  (n={size:,})\" for label, size in zip(cohort_labels, cohort_sizes, strict=True)]\nax.set_yticklabels(y_labels, rotation=0, fontsize=9)\nax.set_xticklabels(period_labels, rotation=0, fontsize=9)\nax.set_xlabel(\"Periods Since Signup\", fontsize=10, labelpad=10)\nax.set_ylabel(\"Signup Cohort\", fontsize=10, labelpad=10)\n\ntitle = \"heatmap-cohort-retention · python · seaborn · anyplot.ai\"\nax.set_title(title, fontsize=12, fontweight=\"medium\", pad=14)\n\n# Colorbar styling\ncbar = ax.collections[0].colorbar\ncbar.ax.tick_params(labelsize=8, colors=INK_SOFT)\ncbar.set_label(\"Retention %\", fontsize=9, labelpad=8, color=INK)\ncbar.outline.set_visible(False)\n\n# Remove all spines; hide tick marks\nsns.despine(ax=ax, top=True, right=True, bottom=True, left=True)\nax.tick_params(axis=\"both\", length=0)\n\n# Explicit layout control — no bbox_inches=\"tight\" (causes canvas drift per seaborn.md)\nfig.subplots_adjust(left=0.20, right=0.89, top=0.91, bottom=0.14)\n\n# Save\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\nplt.close()\n"}