{"spec_id":"line-retention-cohort","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nline-retention-cohort: User Retention Curve by Cohort\nLibrary: plotnine 0.15.7 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_hline,\n    geom_line,\n    geom_point,\n    geom_ribbon,\n    geom_text,\n    ggplot,\n    guide_legend,\n    guides,\n    labs,\n    scale_alpha_identity,\n    scale_color_manual,\n    scale_size_identity,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\n\n\n# Theme tokens (Imprint palette — theme-adaptive 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\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data\nnp.random.seed(42)\n\ncohorts = {\n    \"Jan 2025\": {\"size\": 1245, \"decay\": 0.22, \"plateau\": 8},\n    \"Feb 2025\": {\"size\": 1102, \"decay\": 0.17, \"plateau\": 12},\n    \"Mar 2025\": {\"size\": 1380, \"decay\": 0.13, \"plateau\": 18},\n    \"Apr 2025\": {\"size\": 1290, \"decay\": 0.11, \"plateau\": 22},\n    \"May 2025\": {\"size\": 1455, \"decay\": 0.08, \"plateau\": 30},\n}\n\nweeks = np.arange(0, 13)\nrows = []\n\nfor cohort_name, info in cohorts.items():\n    base = (100 - info[\"plateau\"]) * np.exp(-info[\"decay\"] * weeks) + info[\"plateau\"]\n    noise = np.concatenate(([0], np.cumsum(np.random.normal(0, 0.6, len(weeks) - 1))))\n    retention = np.clip(base + noise, 0, 100)\n    retention[0] = 100.0\n    label = f\"{cohort_name} (n={info['size']:,})\"\n    for w, r in zip(weeks, retention, strict=True):\n        rows.append({\"week\": w, \"retention\": r, \"cohort\": label})\n\ndf = pd.DataFrame(rows)\n\ncohort_labels = list(df[\"cohort\"].unique())\ndf[\"cohort\"] = pd.Categorical(df[\"cohort\"], categories=cohort_labels, ordered=True)\n\n# Alpha: oldest is most faded, newest is full opacity\nalpha_values = [0.6, 0.7, 0.8, 0.9, 1.0]\nalpha_map = dict(zip(cohort_labels, alpha_values, strict=True))\ndf[\"line_alpha\"] = df[\"cohort\"].map(alpha_map).astype(float)\n\n# Line width: thinner for older cohorts, bolder for newer\nsize_values = [1.0, 1.2, 1.4, 1.6, 2.0]\nsize_map = dict(zip(cohort_labels, size_values, strict=True))\ndf[\"line_size\"] = df[\"cohort\"].map(size_map).astype(float)\n\n# Ribbon between oldest and newest cohort to show improvement gap\noldest_label = cohort_labels[0]\nnewest_label = cohort_labels[-1]\ndf_oldest = df[df[\"cohort\"] == oldest_label][[\"week\", \"retention\"]].rename(columns={\"retention\": \"ymin\"})\ndf_newest = df[df[\"cohort\"] == newest_label][[\"week\", \"retention\"]].rename(columns={\"retention\": \"ymax\"})\ndf_ribbon = df_oldest.merge(df_newest, on=\"week\")\n\n# Endpoint labels — stagger the two closest to prevent overlap\ndf_endpoints = df[df[\"week\"] == 12].copy()\ndf_endpoints[\"ret_label\"] = df_endpoints[\"retention\"].apply(lambda x: f\"{x:.0f}%\")\n\nsorted_ends = df_endpoints.sort_values(\"retention\").reset_index(drop=True)\ny_offsets = {row[\"cohort\"]: 0.0 for _, row in df_endpoints.iterrows()}\nif abs(sorted_ends.loc[1, \"retention\"] - sorted_ends.loc[0, \"retention\"]) < 5:\n    y_offsets[sorted_ends.loc[0, \"cohort\"]] = -3.0\n    y_offsets[sorted_ends.loc[1, \"cohort\"]] = 3.0\ndf_endpoints[\"label_y\"] = df_endpoints.apply(lambda row: row[\"retention\"] + y_offsets.get(row[\"cohort\"], 0.0), axis=1)\n\n# Plot\ntitle = \"line-retention-cohort · python · plotnine · anyplot.ai\"\n\nplot = (\n    ggplot(df, aes(x=\"week\", y=\"retention\", color=\"cohort\", group=\"cohort\"))\n    + geom_ribbon(\n        aes(x=\"week\", ymin=\"ymin\", ymax=\"ymax\"), data=df_ribbon, inherit_aes=False, fill=IMPRINT_PALETTE[0], alpha=0.08\n    )\n    + geom_hline(yintercept=20, linetype=\"dashed\", color=INK_SOFT, size=0.7)\n    + geom_line(aes(alpha=\"line_alpha\", size=\"line_size\"))\n    + scale_alpha_identity()\n    + scale_size_identity()\n    + geom_point(aes(alpha=\"line_alpha\"), size=2.5, show_legend=False)\n    + geom_text(\n        aes(y=\"label_y\", label=\"ret_label\"),\n        data=df_endpoints,\n        nudge_x=0.45,\n        size=3.0,\n        ha=\"left\",\n        show_legend=False,\n        color=INK_SOFT,\n    )\n    + scale_color_manual(values=IMPRINT_PALETTE)\n    + scale_x_continuous(breaks=list(range(0, 13)), labels=[str(w) for w in range(0, 13)], expand=(0.02, 0.8))\n    + scale_y_continuous(\n        limits=(0, 108), breaks=[0, 20, 40, 60, 80, 100], labels=[\"0%\", \"20%\", \"40%\", \"60%\", \"80%\", \"100%\"]\n    )\n    + annotate(\"text\", x=8, y=22.5, label=\"20% threshold\", size=2.5, color=INK_MUTED, ha=\"right\", fontstyle=\"italic\")\n    + annotate(\n        \"label\",\n        x=6,\n        y=55,\n        label=\"Improvement\\ngap\",\n        size=3.0,\n        color=INK_SOFT,\n        fill=ELEVATED_BG,\n        alpha=0.85,\n        ha=\"center\",\n        label_size=0,\n    )\n    + labs(x=\"Weeks Since Signup\", y=\"Retained Users (%)\", color=\"Cohort\", title=title)\n    + guides(color=guide_legend(override_aes={\"size\": 3, \"alpha\": 1}))\n    + theme_minimal()\n    + theme(\n        figure_size=(8, 4.5),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        text=element_text(family=\"sans-serif\", size=7, color=INK_SOFT),\n        plot_title=element_text(size=12, weight=\"bold\", color=INK),\n        axis_title=element_text(size=10, color=INK),\n        axis_text=element_text(size=8, color=INK_SOFT),\n        legend_title=element_text(size=8, weight=\"bold\", color=INK),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_position=\"right\",\n        legend_background=element_rect(fill=ELEVATED_BG, color=\"none\"),\n        legend_key=element_rect(fill=\"none\", color=\"none\"),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor=element_blank(),\n        panel_grid_major_y=element_line(color=INK, size=0.3, alpha=0.15),\n        axis_line_x=element_line(color=INK_SOFT, size=0.5),\n        axis_line_y=element_line(color=INK_SOFT, size=0.5),\n        plot_margin=0.04,\n    )\n)\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\", verbose=False)\n"}