{"spec_id":"survival-kaplan-meier","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nsurvival-kaplan-meier: Kaplan-Meier Survival Plot\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-11\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, Span\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens\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\"\n\nTREATMENT_COLOR = \"#009E73\"  # Okabe-Ito position 1 (brand green)\nCONTROL_COLOR = \"#C475FD\"  # Okabe-Ito position 2 (vermillion)\n\n# Data - Simulated clinical trial with two treatment groups\nnp.random.seed(42)\n\n# Treatment group (new drug) - better survival\nn_treatment = 80\ntreatment_times = np.random.exponential(scale=24, size=n_treatment)\ntreatment_times = np.clip(treatment_times, 0.5, 36)\ntreatment_censored = np.random.binomial(1, 0.35, n_treatment)\ntreatment_events = 1 - treatment_censored\n\n# Control group (standard care) - worse survival\nn_control = 80\ncontrol_times = np.random.exponential(scale=16, size=n_control)\ncontrol_times = np.clip(control_times, 0.5, 36)\ncontrol_censored = np.random.binomial(1, 0.3, n_control)\ncontrol_events = 1 - control_censored\n\n\n# Calculate Kaplan-Meier estimator\ndef compute_km(times, events):\n    \"\"\"Compute Kaplan-Meier survival curve with 95% confidence intervals.\"\"\"\n    order = np.argsort(times)\n    times = times[order]\n    events = events[order]\n\n    unique_event_times = np.unique(times[events == 1])\n\n    survival = [1.0]\n    time_points = [0.0]\n    var_sum = 0\n    ci_lower = [1.0]\n    ci_upper = [1.0]\n    censored_times = []\n    censored_survival = []\n\n    for t in unique_event_times:\n        at_risk = np.sum(times >= t)\n        d = np.sum((times == t) & (events == 1))\n\n        if at_risk > 0:\n            s_prob = 1 - d / at_risk\n            survival.append(survival[-1] * s_prob)\n            time_points.append(t)\n\n            if at_risk > d:\n                var_sum += d / (at_risk * (at_risk - d))\n\n            se = np.sqrt(var_sum) if var_sum > 0 else 0\n            log_s = np.log(survival[-1]) if survival[-1] > 0 else -np.inf\n            ci_factor = 1.96 * se / abs(log_s) if log_s != 0 else 0\n\n            ci_lower.append(survival[-1] ** np.exp(ci_factor))\n            ci_upper.append(survival[-1] ** np.exp(-ci_factor))\n\n    # Find censored observation positions\n    for t, e in zip(times, events, strict=True):\n        if e == 0:\n            idx = np.searchsorted(time_points[1:], t, side=\"right\")\n            if idx < len(survival):\n                censored_times.append(t)\n                censored_survival.append(survival[idx])\n\n    return (\n        np.array(time_points),\n        np.array(survival),\n        np.array(ci_lower),\n        np.array(ci_upper),\n        np.array(censored_times),\n        np.array(censored_survival),\n    )\n\n\nt_time, t_surv, t_ci_low, t_ci_up, t_cens_t, t_cens_s = compute_km(treatment_times, treatment_events)\nc_time, c_surv, c_ci_low, c_ci_up, c_cens_t, c_cens_s = compute_km(control_times, control_events)\n\n\n# Convert to step functions\ndef to_steps(x, y):\n    \"\"\"Convert point data to step function.\"\"\"\n    x_step = np.repeat(x, 2)[1:]\n    y_step = np.repeat(y, 2)[:-1]\n    return x_step, y_step\n\n\nt_x_step, t_y_step = to_steps(t_time, t_surv)\nc_x_step, c_y_step = to_steps(c_time, c_surv)\n\nt_ci_low_x, t_ci_low_y = to_steps(t_time, t_ci_low)\nt_ci_up_x, t_ci_up_y = to_steps(t_time, t_ci_up)\nc_ci_low_x, c_ci_low_y = to_steps(c_time, c_ci_low)\nc_ci_up_x, c_ci_up_y = to_steps(c_time, c_ci_up)\n\n# Create figure\np = figure(\n    width=4800,\n    height=2700,\n    title=\"survival-kaplan-meier · bokeh · anyplot.ai\",\n    x_axis_label=\"Time (months)\",\n    y_axis_label=\"Survival Probability\",\n    x_range=(0, 38),\n    y_range=(0, 1.05),\n)\n\n# Plot confidence interval bands\ntreatment_ci = ColumnDataSource(\n    data={\"x\": np.concatenate([t_ci_low_x, t_ci_up_x[::-1]]), \"y\": np.concatenate([t_ci_low_y, t_ci_up_y[::-1]])}\n)\ncontrol_ci = ColumnDataSource(\n    data={\"x\": np.concatenate([c_ci_low_x, c_ci_up_x[::-1]]), \"y\": np.concatenate([c_ci_low_y, c_ci_up_y[::-1]])}\n)\n\np.patch(x=\"x\", y=\"y\", source=treatment_ci, fill_color=TREATMENT_COLOR, fill_alpha=0.15, line_alpha=0)\np.patch(x=\"x\", y=\"y\", source=control_ci, fill_color=CONTROL_COLOR, fill_alpha=0.15, line_alpha=0)\n\n# Plot survival curves\np.line(x=t_x_step, y=t_y_step, line_color=TREATMENT_COLOR, line_width=4, legend_label=\"Treatment (n=80)\")\np.line(x=c_x_step, y=c_y_step, line_color=CONTROL_COLOR, line_width=4, legend_label=\"Control (n=80)\")\n\n# Plot censored observations as tick marks\nif len(t_cens_t) > 0:\n    p.scatter(x=t_cens_t, y=t_cens_s, marker=\"dash\", size=28, angle=1.5708, line_color=TREATMENT_COLOR, line_width=4)\nif len(c_cens_t) > 0:\n    p.scatter(x=c_cens_t, y=c_cens_s, marker=\"dash\", size=28, angle=1.5708, line_color=CONTROL_COLOR, line_width=4)\n\n# Add median survival reference line\nmedian_line = Span(location=0.5, dimension=\"width\", line_color=INK_SOFT, line_width=3, line_dash=\"dashed\")\np.add_layout(median_line)\n\n# Style\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\n\np.title.text_color = INK\np.title.text_font_size = \"28pt\"\n\np.xaxis.axis_label_text_color = INK\np.xaxis.axis_label_text_font_size = \"22pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.xaxis.major_label_text_font_size = \"18pt\"\np.xaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\n\np.yaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_font_size = \"22pt\"\np.yaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_font_size = \"18pt\"\np.yaxis.axis_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\n\np.xgrid.grid_line_color = INK\np.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.10\np.ygrid.grid_line_alpha = 0.10\n\np.legend.location = \"bottom_left\"\np.legend.label_text_color = INK_SOFT\np.legend.label_text_font_size = \"20pt\"\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\n\n# Save\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome\nW, H = 4800, 2700\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}