{"spec_id":"line-retention-cohort","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nline-retention-cohort: User Retention Curve by Cohort\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\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Imprint palette (canonical order, 5 cohorts)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Theme-adaptive chrome tokens\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\"\nGRID_COLOR = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# Data: monthly signup cohorts tracked weekly for 12 weeks\nnp.random.seed(42)\nweeks = np.arange(0, 13)\n\ncohorts = {\n    \"Jan 2025\": {\"size\": 1245, \"decay\": 0.18},\n    \"Feb 2025\": {\"size\": 1102, \"decay\": 0.16},\n    \"Mar 2025\": {\"size\": 1380, \"decay\": 0.14},\n    \"Apr 2025\": {\"size\": 1510, \"decay\": 0.12},\n    \"May 2025\": {\"size\": 1425, \"decay\": 0.10},\n}\n\nrows = []\nfor cohort_name, params in cohorts.items():\n    retention = 100 * np.exp(-params[\"decay\"] * weeks)\n    noise = np.random.normal(0, 1.5, len(weeks))\n    noise[0] = 0\n    retention = np.clip(retention + noise, 0, 100)\n    retention[0] = 100.0\n    label = f\"{cohort_name} (n={params['size']:,})\"\n    for w, r in zip(weeks, retention, strict=False):\n        rows.append({\"Week\": w, \"Retention\": r, \"Cohort\": label})\n\ndf = pd.DataFrame(rows)\n\n# Endpoint labels at week 12 with overlap prevention\nendpoints = df[df[\"Week\"] == 12].copy()\nendpoints[\"label\"] = endpoints[\"Retention\"].apply(lambda x: f\"{x:.0f}%\")\nsorted_ep = endpoints.sort_values(\"Retention\").reset_index(drop=True)\nmin_gap = 5.0  # larger gap to ensure labels don't crowd at lower retention values\nfor i in range(1, len(sorted_ep)):\n    if sorted_ep.loc[i, \"Retention\"] - sorted_ep.loc[i - 1, \"Retention\"] < min_gap:\n        sorted_ep.loc[i, \"Retention\"] = sorted_ep.loc[i - 1, \"Retention\"] + min_gap\nendpoints = sorted_ep\n\n# Line widths: older cohorts thinner, newer cohorts bolder for visual hierarchy\nline_widths = [1.0, 1.5, 2.0, 2.5, 3.0]\ncohort_labels = [f\"{k} (n={v['size']:,})\" for k, v in cohorts.items()]\n\nplot = ggplot()\n\n# Per-cohort lines with progressive widths\nfor i, cohort_label in enumerate(cohort_labels):\n    cdf = df[df[\"Cohort\"] == cohort_label]\n    plot = plot + geom_line(\n        aes(x=\"Week\", y=\"Retention\", color=\"Cohort\"),\n        data=cdf,\n        size=line_widths[i],\n        alpha=0.9,\n        tooltips=layer_tooltips().line(\"@Cohort\").line(\"Week @Week\").line(\"Retention @Retention{.1f}%\"),\n    )\n\nplot = (\n    plot\n    + geom_point(aes(x=\"Week\", y=\"Retention\", color=\"Cohort\"), data=df, size=2.5, alpha=0.85)\n    + geom_hline(yintercept=20, linetype=\"dashed\", color=INK_MUTED, size=0.7)\n    + geom_text(\n        aes(x=\"Week\", y=\"Retention\", label=\"label\", color=\"Cohort\"), data=endpoints, size=4, nudge_x=0.55, hjust=0\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=pd.DataFrame({\"x\": [0.2], \"y\": [20], \"label\": [\"20% target\"]}),\n        size=3.5,\n        color=INK_MUTED,\n        hjust=0,\n        vjust=-1.2,\n    )\n    + scale_color_manual(values=IMPRINT_PALETTE)\n    + scale_x_continuous(breaks=list(range(0, 13, 2)), limits=[0, 15.5])\n    + scale_y_continuous(breaks=list(range(0, 101, 20)), limits=[0, 105])\n    + labs(\n        title=\"line-retention-cohort · python · letsplot · anyplot.ai\", x=\"Weeks Since Signup\", y=\"Retained Users (%)\"\n    )\n    + theme_minimal()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        plot_title=element_text(size=16, hjust=0.5, face=\"bold\", color=INK),\n        axis_title=element_text(size=12, color=INK),\n        axis_text=element_text(size=10, color=INK_SOFT),\n        legend_title=element_blank(),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_position=\"right\",\n        panel_grid_major=element_line(color=GRID_COLOR, size=0.3),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor=element_blank(),\n        panel_border=element_blank(),\n        axis_line=element_line(color=INK_SOFT),\n    )\n    + ggsize(800, 450)\n)\n\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}