User Retention Curve by Cohort — Bokeh

A line chart showing the percentage of retained users over time since signup, with separate curves for different cohorts. All curves start at 100% at time zero and typically exhibit exponential decay, revealing how well a product retains users over their lifecycle. By overlaying multiple cohorts, teams can visually compare whether retention is improving or degrading across signup periods.

User Retention Curve by Cohort rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
line-retention-cohort: User Retention Curve by Cohort
Library: bokeh 3.9.1 | Python 3.13.14
Quality: 88/100 | Updated: 2026-06-20
"""

import io
import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, HoverTool, Label, Legend, Span
from bokeh.plotting import figure
from PIL import Image
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme tokens — Imprint palette, theme-adaptive chrome
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint categorical palette — positions 1-5
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030"]

# Data — wider decay spread (0.22 → 0.06) so curves diverge clearly at week 12
np.random.seed(42)
weeks = np.arange(0, 13)

cohorts = {
    "Jan 2025": {"size": 1245, "decay": 0.22},
    "Feb 2025": {"size": 1380, "decay": 0.17},
    "Mar 2025": {"size": 1520, "decay": 0.13},
    "Apr 2025": {"size": 1410, "decay": 0.09},
    "May 2025": {"size": 1680, "decay": 0.06},
}

retention_data = {}
for cohort, params in cohorts.items():
    base = 100 * np.exp(-params["decay"] * weeks)
    noise = np.random.normal(0, 1.2, len(weeks))
    retention = np.clip(base + noise, 0, 100)
    retention[0] = 100.0
    retention_data[cohort] = retention

# Title — 52 chars, under the 67-char baseline; no scaling needed
title = "line-retention-cohort · python · bokeh · anyplot.ai"

# Plot — 3200×1800 landscape canvas, toolbar hidden for static PNG
p = figure(
    width=3200,
    height=1800,
    title=title,
    x_axis_label="Weeks Since Signup",
    y_axis_label="Retention Rate (%)",
    toolbar_location=None,
    min_border_bottom=160,
    min_border_left=180,
    min_border_top=130,
    min_border_right=60,
)

# Progressive visual weight: older cohorts are thinner and more transparent
line_widths = [2.5, 3.0, 3.5, 4.5, 5.5]
alphas = [0.55, 0.65, 0.75, 0.88, 1.0]
dash_patterns = ["dashed", "dashed", "solid", "solid", "solid"]

legend_items = []
for i, (cohort, params) in enumerate(cohorts.items()):
    source = ColumnDataSource(
        data={
            "week": weeks,
            "retention": retention_data[cohort],
            "cohort": [cohort] * len(weeks),
            "size": [params["size"]] * len(weeks),
            "retention_fmt": [f"{r:.1f}" for r in retention_data[cohort]],
        }
    )
    label = f"{cohort} (n={params['size']:,})"
    color = IMPRINT_PALETTE[i]

    line = p.line(
        x="week",
        y="retention",
        source=source,
        line_width=line_widths[i],
        line_color=color,
        line_alpha=alphas[i],
        line_dash=dash_patterns[i],
    )
    scatter = p.scatter(
        x="week",
        y="retention",
        source=source,
        size=9 + i * 2,
        fill_color=color,
        fill_alpha=alphas[i],
        line_color=PAGE_BG,
        line_width=2,
    )
    legend_items.append((label, [line, scatter]))

# HoverTool for interactive HTML
hover = HoverTool(
    tooltips=[("Cohort", "@cohort"), ("Week", "@week"), ("Retention", "@retention_fmt%"), ("Cohort Size", "@size{,}")],
    mode="mouse",
)
p.add_tools(hover)

# Reference line at 20% retention threshold
threshold = Span(
    location=20, dimension="width", line_color=INK_MUTED, line_dash="dashed", line_width=2.5, line_alpha=0.75
)
p.add_layout(threshold)

threshold_label = Label(
    x=11.8, y=20, text="20% threshold", text_font_size="26pt", text_color=INK_MUTED, y_offset=10, text_align="right"
)
p.add_layout(threshold_label)

# Legend — larger text for canvas readability
legend = Legend(items=legend_items, location="top_right")
legend.label_text_font_size = "34pt"
legend.label_text_color = INK_SOFT
legend.glyph_height = 36
legend.glyph_width = 36
legend.spacing = 14
legend.padding = 24
legend.background_fill_color = ELEVATED_BG
legend.background_fill_alpha = 0.92
legend.border_line_color = INK_SOFT
legend.border_line_alpha = 0.3
p.add_layout(legend)

# Axis ranges
p.y_range.start = 0
p.y_range.end = 105
p.x_range.start = -0.3
p.x_range.end = 12.3

# Theme-adaptive chrome
p.title.text_font_size = "50pt"
p.title.text_color = INK

p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.axis_label_text_font_style = "normal"
p.yaxis.axis_label_text_font_style = "normal"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK

p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT

p.xaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT
p.xaxis.minor_tick_line_color = None
p.yaxis.minor_tick_line_color = None

p.xgrid.grid_line_alpha = 0
p.ygrid.grid_line_color = INK
p.ygrid.grid_line_alpha = 0.12

p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = None  # remove box frame — L-shape via axes only

p.axis.axis_line_width = 2

# Save HTML (no toolbar) for screenshot
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot via headless Chrome — use oversized viewport then crop to exact canvas
W, H = 3200, 1800
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H + 200}",  # extra height absorbs any browser chrome overhead
    "--hide-scrollbars",
    "--force-device-scale-factor=1",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H + 200)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
raw_png = driver.get_screenshot_as_png()
driver.quit()
img = Image.open(io.BytesIO(raw_png)).crop((0, 0, W, H))
img.save(f"plot-{THEME}.png")

# Re-save HTML with toolbar for the interactive catalog artifact
p.toolbar_location = "above"
output_file(f"plot-{THEME}.html")
save(p)

Part of User Retention Curve by Cohort on anyplot.ai.

Other implementations