{"spec_id":"line-retention-cohort","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nline-retention-cohort: User Retention Curve by Cohort\nLibrary: seaborn 0.13.2 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\n\n\n# Theme tokens — Imprint palette\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 categorical palette — canonical order, first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data — 50 simulations per cohort so seaborn can draw 95% CI bands\nnp.random.seed(42)\n\ncohorts = {\"Jan 2025\": 1245, \"Feb 2025\": 1380, \"Mar 2025\": 1510, \"Apr 2025\": 1420, \"May 2025\": 1605}\n\nweeks = np.arange(0, 13)\ndecay_rates = [0.18, 0.16, 0.14, 0.12, 0.10]\nfloors = [8, 10, 14, 18, 22]\nn_sims = 50\n\nrecords = []\nmean_final = {}\n\nfor (cohort_label, cohort_size), decay, floor in zip(cohorts.items(), decay_rates, floors, strict=True):\n    label = f\"{cohort_label} (n={cohort_size:,})\"\n    sim_finals = []\n    for _ in range(n_sims):\n        sim_decay = max(0.05, decay + np.random.normal(0, 0.018))\n        retention = 100 * np.exp(-sim_decay * weeks) + floor * (1 - np.exp(-0.3 * weeks))\n        retention[0] = 100.0\n        noise = np.random.normal(0, 1.5, len(weeks))\n        noise[0] = 0\n        retention = np.clip(retention + noise, 0, 100)\n        sim_finals.append(float(retention[-1]))\n        for w, r in zip(weeks, retention, strict=True):\n            records.append({\"week\": w, \"retention\": r, \"cohort\": label})\n    mean_final[label] = float(np.mean(sim_finals))\n\ndf = pd.DataFrame(records)\ncohort_labels = list(mean_final.keys())\n\noldest_mean = mean_final[cohort_labels[0]]\nnewest_mean = mean_final[cohort_labels[-1]]\ndelta_pp = newest_mean - oldest_mean\n\n# Seaborn theme with Imprint chrome tokens\nsns.set_theme(\n    style=\"ticks\",\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        \"grid.color\": INK,\n        \"grid.alpha\": 0.12,\n        \"legend.facecolor\": ELEVATED_BG,\n        \"legend.edgecolor\": INK_SOFT,\n    },\n)\n\n# Canvas — 3200 × 1800 px (landscape, no bbox_inches to preserve exact size)\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\n# Plot — errorbar=(\"ci\", 95) is seaborn's built-in statistical CI band, drawn natively\nsns.lineplot(\n    data=df,\n    x=\"week\",\n    y=\"retention\",\n    hue=\"cohort\",\n    hue_order=cohort_labels,\n    palette=IMPRINT_PALETTE,\n    linewidth=2.5,\n    errorbar=(\"ci\", 95),\n    ax=ax,\n)\n\n# 20% retention target reference line\nax.axhline(y=20, color=INK_MUTED, linestyle=\"--\", linewidth=0.9, alpha=0.7, zorder=1)\nax.text(0.3, 17, \"20% target\", fontsize=8, color=INK_MUTED, va=\"center\", fontstyle=\"italic\")\n\n# +Xpp improvement annotation — replaces per-cohort endpoint labels (change request)\nmid_y = (oldest_mean + newest_mean) / 2\nax.annotate(\n    \"\", xy=(12, oldest_mean), xytext=(12, newest_mean), arrowprops={\"arrowstyle\": \"<->\", \"color\": INK_SOFT, \"lw\": 1.0}\n)\nax.text(\n    12.25,\n    mid_y,\n    f\"+{delta_pp:.0f}pp\\nimprovement\",\n    fontsize=8,\n    fontweight=\"bold\",\n    color=INK_SOFT,\n    va=\"center\",\n    ha=\"left\",\n    linespacing=1.3,\n)\n\n# Style\ntitle = \"line-retention-cohort · python · seaborn · anyplot.ai\"\nax.set_title(title, fontsize=12, fontweight=\"medium\", color=INK, pad=12)\nax.set_xlabel(\"Weeks Since Signup\", fontsize=10, color=INK, labelpad=8)\nax.set_ylabel(\"Retained Users (%)\", fontsize=10, color=INK, labelpad=8)\nax.tick_params(axis=\"both\", labelsize=8, colors=INK_SOFT)\n\nax.set_xlim(-0.3, 14.2)\nax.set_ylim(0, 108)\nax.set_xticks(weeks)\n\nax.yaxis.grid(True, alpha=0.12, linewidth=0.8, color=INK)\nax.xaxis.grid(False)\n\nsns.despine(ax=ax, left=True, bottom=False)\n\n# Legend with Imprint chrome tokens\nlegend = ax.legend(title=\"Signup Cohort\", fontsize=8, title_fontsize=9, frameon=True, loc=\"upper right\")\nlegend.get_frame().set_facecolor(ELEVATED_BG)\nlegend.get_frame().set_edgecolor(INK_SOFT)\nlegend.get_frame().set_linewidth(0.5)\nlegend.get_title().set_color(INK)\nfor text in legend.get_texts():\n    text.set_color(INK_SOFT)\n\n# Save — no bbox_inches to preserve exact 3200×1800 canvas\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\nplt.close()\n"}