{"spec_id":"line-retention-cohort","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nline-retention-cohort: User Retention Curve by Cohort\nLibrary: plotly 6.8.0 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme tokens — Imprint palette (see prompts/default-style-guide.md)\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\"\nGRID = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data — plateau-based retention decay: retention(t) = plateau + (100 - plateau) * exp(-steep * t)\n# Later cohorts achieve higher plateaus, showing improving long-term retention.\nnp.random.seed(42)\n\ncohorts = {\n    \"Jan 2025\": {\"size\": 1245, \"plateau\": 8, \"steep\": 0.35},\n    \"Feb 2025\": {\"size\": 1102, \"plateau\": 11, \"steep\": 0.33},\n    \"Mar 2025\": {\"size\": 1380, \"plateau\": 14, \"steep\": 0.30},\n    \"Apr 2025\": {\"size\": 1510, \"plateau\": 17, \"steep\": 0.27},\n    \"May 2025\": {\"size\": 1425, \"plateau\": 21, \"steep\": 0.24},\n}\n\nweeks = np.arange(0, 13)\n\nretention_data = {}\nfor cohort, params in cohorts.items():\n    base = params[\"plateau\"] + (100 - params[\"plateau\"]) * np.exp(-params[\"steep\"] * weeks)\n    noise = np.random.normal(0, 1.2, len(weeks))\n    noise[0] = 0\n    retention = np.clip(base + noise, 0, 100)\n    retention[0] = 100.0\n    retention_data[cohort] = retention\n\n# Older cohorts thinner / less opaque to emphasize recent improvement\nline_widths = [1.8, 2.0, 2.2, 2.5, 3.0]\nopacities = [0.55, 0.65, 0.75, 0.85, 1.0]\n\n# Plot\nfig = go.Figure()\n\nfor i, (cohort, retention) in enumerate(retention_data.items()):\n    params = cohorts[cohort]\n    color = IMPRINT_PALETTE[i]\n    rgb = tuple(int(color[j : j + 2], 16) for j in (1, 3, 5))\n    fill_alpha = 0.04 + i * 0.02\n\n    fig.add_trace(\n        go.Scatter(\n            x=weeks,\n            y=retention,\n            mode=\"none\",\n            fill=\"tozeroy\",\n            fillcolor=f\"rgba({rgb[0]},{rgb[1]},{rgb[2]},{fill_alpha})\",\n            showlegend=False,\n            hoverinfo=\"skip\",\n        )\n    )\n\n    fig.add_trace(\n        go.Scatter(\n            x=weeks,\n            y=retention,\n            mode=\"lines+markers\",\n            name=f\"{cohort} (n={params['size']:,})\",\n            line={\"color\": color, \"width\": line_widths[i]},\n            marker={\"size\": 7 + i, \"color\": color},\n            opacity=opacities[i],\n        )\n    )\n\n# 20% long-term retention threshold\nfig.add_hline(\n    y=20,\n    line_dash=\"dash\",\n    line_color=INK_SOFT,\n    line_width=1.5,\n    annotation_text=\"20% threshold\",\n    annotation_position=\"top left\",\n    annotation_font={\"size\": 10, \"color\": INK_SOFT},\n)\n\n# Title — compute fontsize from length (baseline 16px for ~67 chars)\ntitle = \"line-retention-cohort · python · plotly · anyplot.ai\"\nn = len(title)\ntitle_fontsize = max(round(16 * 67 / n) if n > 67 else 16, 11)\n\n# Style\nfig.update_layout(\n    autosize=False,\n    title={\"text\": title, \"font\": {\"size\": title_fontsize, \"color\": INK}, \"x\": 0.5, \"xanchor\": \"center\"},\n    xaxis={\n        \"title\": {\"text\": \"Weeks Since Signup\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"dtick\": 1,\n        \"showgrid\": False,\n        \"showline\": True,\n        \"linecolor\": INK_SOFT,\n        \"zerolinecolor\": INK_SOFT,\n    },\n    yaxis={\n        \"title\": {\"text\": \"Retained Users (%)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"range\": [0, 105],\n        \"ticksuffix\": \"%\",\n        \"dtick\": 20,\n        \"showgrid\": True,\n        \"gridcolor\": GRID,\n        \"gridwidth\": 1,\n        \"showline\": True,\n        \"linecolor\": INK_SOFT,\n        \"zerolinecolor\": INK_SOFT,\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    legend={\n        \"font\": {\"size\": 10, \"color\": INK_SOFT},\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"yanchor\": \"top\",\n        \"y\": 0.95,\n        \"xanchor\": \"right\",\n        \"x\": 0.98,\n    },\n    margin={\"l\": 80, \"r\": 40, \"t\": 80, \"b\": 60},\n)\n\n# Save\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\n"}