{"spec_id":"survival-kaplan-meier","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nsurvival-kaplan-meier: Kaplan-Meier Survival Plot\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 84/100 | Updated: 2026-05-11\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Okabe-Ito palette (first series = brand green, second = vermillion)\nBRAND = \"#009E73\"\nSECONDARY = \"#C475FD\"\n\n# Set seed for reproducibility\nnp.random.seed(42)\n\n# Generate synthetic clinical trial survival data\n# Two treatment groups: Standard (control) and Experimental (new treatment)\nn_per_group = 80\n\n# Standard treatment group - shorter median survival\nsurvival_times_standard = np.concatenate(\n    [np.random.exponential(scale=18, size=50), np.random.exponential(scale=8, size=30)]\n)\ncensored_standard = np.random.binomial(1, 0.25, size=n_per_group)\nevent_standard = 1 - censored_standard\n\n# Experimental treatment group - longer median survival\nsurvival_times_experimental = np.concatenate(\n    [np.random.exponential(scale=28, size=55), np.random.exponential(scale=12, size=25)]\n)\ncensored_experimental = np.random.binomial(1, 0.30, size=n_per_group)\nevent_experimental = 1 - censored_experimental\n\n# Calculate Kaplan-Meier for Standard treatment\norder_std = np.argsort(survival_times_standard)\ntimes_std_sorted = survival_times_standard[order_std]\nevents_std_sorted = event_standard[order_std]\ncensored_std_sorted = censored_standard[order_std]\n\nunique_times_std = np.unique(times_std_sorted[events_std_sorted == 1])\nsurvival_std = [1.0]\ntime_points_std = [0.0]\nn_at_risk_std = len(times_std_sorted)\n\nfor t in unique_times_std:\n    d = np.sum((times_std_sorted == t) & (events_std_sorted == 1))\n    if len(time_points_std) > 1:\n        prev_t = time_points_std[-1]\n        lost = np.sum((times_std_sorted > prev_t) & (times_std_sorted < t))\n        n_at_risk_std -= lost\n    if n_at_risk_std > 0:\n        s = survival_std[-1] * (1 - d / n_at_risk_std)\n    else:\n        s = survival_std[-1]\n    time_points_std.append(t)\n    survival_std.append(s)\n    n_at_risk_std -= d\n\ntime_std = np.array(time_points_std)\nsurv_std = np.array(survival_std)\n\n# Calculate Kaplan-Meier for Experimental treatment\norder_exp = np.argsort(survival_times_experimental)\ntimes_exp_sorted = survival_times_experimental[order_exp]\nevents_exp_sorted = event_experimental[order_exp]\ncensored_exp_sorted = censored_experimental[order_exp]\n\nunique_times_exp = np.unique(times_exp_sorted[events_exp_sorted == 1])\nsurvival_exp = [1.0]\ntime_points_exp = [0.0]\nn_at_risk_exp = len(times_exp_sorted)\n\nfor t in unique_times_exp:\n    d = np.sum((times_exp_sorted == t) & (events_exp_sorted == 1))\n    if len(time_points_exp) > 1:\n        prev_t = time_points_exp[-1]\n        lost = np.sum((times_exp_sorted > prev_t) & (times_exp_sorted < t))\n        n_at_risk_exp -= lost\n    if n_at_risk_exp > 0:\n        s = survival_exp[-1] * (1 - d / n_at_risk_exp)\n    else:\n        s = survival_exp[-1]\n    time_points_exp.append(t)\n    survival_exp.append(s)\n    n_at_risk_exp -= d\n\ntime_exp = np.array(time_points_exp)\nsurv_exp = np.array(survival_exp)\n\n# Create step function data for pygal XY chart\nstep_std = []\nfor i in range(len(time_std)):\n    if i > 0:\n        step_std.append((float(time_std[i]), float(surv_std[i - 1])))\n    step_std.append((float(time_std[i]), float(surv_std[i])))\n\nstep_exp = []\nfor i in range(len(time_exp)):\n    if i > 0:\n        step_exp.append((float(time_exp[i]), float(surv_exp[i - 1])))\n    step_exp.append((float(time_exp[i]), float(surv_exp[i])))\n\n# Calculate 95% confidence intervals using Greenwood's formula\n# Standard treatment CI\nvar_std = []\nn_risk_std = len(times_std_sorted)\ncumvar_std = 0.0\nfor i in range(len(time_std)):\n    if i == 0:\n        var_std.append(0.0)\n    else:\n        mask = times_std_sorted == time_std[i]\n        d = np.sum(mask & (events_std_sorted == 1))\n        if n_risk_std > 0 and d > 0:\n            cumvar_std += d / (n_risk_std * (n_risk_std - d + 0.001))\n        var_std.append(cumvar_std)\n        n_risk_std -= np.sum(mask)\n\nse_std = surv_std * np.sqrt(var_std)\nci_upper_std = np.minimum(surv_std + 1.96 * se_std, 1.0)\nci_lower_std = np.maximum(surv_std - 1.96 * se_std, 0.0)\n\n# Experimental treatment CI\nvar_exp = []\nn_risk_exp = len(times_exp_sorted)\ncumvar_exp = 0.0\nfor i in range(len(time_exp)):\n    if i == 0:\n        var_exp.append(0.0)\n    else:\n        mask = times_exp_sorted == time_exp[i]\n        d = np.sum(mask & (events_exp_sorted == 1))\n        if n_risk_exp > 0 and d > 0:\n            cumvar_exp += d / (n_risk_exp * (n_risk_exp - d + 0.001))\n        var_exp.append(cumvar_exp)\n        n_risk_exp -= np.sum(mask)\n\nse_exp = surv_exp * np.sqrt(var_exp)\nci_upper_exp = np.minimum(surv_exp + 1.96 * se_exp, 1.0)\nci_lower_exp = np.maximum(surv_exp - 1.96 * se_exp, 0.0)\n\n# Create CI band step data - upper bound then back via lower for fill effect\nci_band_std = []\nfor i in range(len(time_std)):\n    if i > 0:\n        ci_band_std.append((float(time_std[i]), float(ci_upper_std[i - 1])))\n    ci_band_std.append((float(time_std[i]), float(ci_upper_std[i])))\n# Trace back along lower bound (reversed)\nfor i in range(len(time_std) - 1, -1, -1):\n    ci_band_std.append((float(time_std[i]), float(ci_lower_std[i])))\n    if i > 0:\n        ci_band_std.append((float(time_std[i]), float(ci_lower_std[i - 1])))\n\nci_band_exp = []\nfor i in range(len(time_exp)):\n    if i > 0:\n        ci_band_exp.append((float(time_exp[i]), float(ci_upper_exp[i - 1])))\n    ci_band_exp.append((float(time_exp[i]), float(ci_upper_exp[i])))\n# Trace back along lower bound (reversed)\nfor i in range(len(time_exp) - 1, -1, -1):\n    ci_band_exp.append((float(time_exp[i]), float(ci_lower_exp[i])))\n    if i > 0:\n        ci_band_exp.append((float(time_exp[i]), float(ci_lower_exp[i - 1])))\n\n# Get censored observation points - create vertical tick marks\n# Each tick is a short vertical line segment at the survival probability\ntick_height = 0.03  # Height of censored tick marks\ncensored_times_std = times_std_sorted[censored_std_sorted == 1]\ncensored_ticks_std = []\nfor ct in censored_times_std:\n    idx = np.searchsorted(time_std, ct, side=\"right\") - 1\n    idx = max(0, min(idx, len(surv_std) - 1))\n    y_val = float(surv_std[idx])\n    # Each tick is a pair of points (vertical line segment)\n    censored_ticks_std.append((float(ct), y_val - tick_height / 2))\n    censored_ticks_std.append((float(ct), y_val + tick_height / 2))\n    censored_ticks_std.append((None, None))  # Break to create separate tick marks\n\ncensored_times_exp = times_exp_sorted[censored_exp_sorted == 1]\ncensored_ticks_exp = []\nfor ct in censored_times_exp:\n    idx = np.searchsorted(time_exp, ct, side=\"right\") - 1\n    idx = max(0, min(idx, len(surv_exp) - 1))\n    y_val = float(surv_exp[idx])\n    censored_ticks_exp.append((float(ct), y_val - tick_height / 2))\n    censored_ticks_exp.append((float(ct), y_val + tick_height / 2))\n    censored_ticks_exp.append((None, None))\n\n# Custom style with theme-adaptive colors\nbrand_ci_light = \"rgba(0, 158, 115, 0.15)\"  # Brand green, light alpha\nbrand_ci_dark = \"rgba(0, 158, 115, 0.15)\"\nsecondary_ci_light = \"rgba(196, 117, 253, 0.15)\"  # Secondary orange, light alpha\nsecondary_ci_dark = \"rgba(196, 117, 253, 0.15)\"\n\n# CI band colors match data colors with transparency\nci_colors = (\n    brand_ci_light if THEME == \"light\" else brand_ci_dark,\n    secondary_ci_light if THEME == \"light\" else secondary_ci_dark,\n    BRAND,\n    SECONDARY,\n    BRAND,\n    SECONDARY,\n)\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=ci_colors,\n    title_font_size=28,\n    label_font_size=22,\n    major_label_font_size=18,\n    legend_font_size=16,\n    value_font_size=14,\n    stroke_width=3,\n    guide_stroke_dasharray=\"0\",\n    major_guide_stroke_dasharray=\"0\",\n    font_family=\"sans-serif\",\n)\n\n# Create XY chart for survival curves\nchart = pygal.XY(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"survival-kaplan-meier · pygal · anyplot.ai\",\n    x_title=\"Time (Months)\",\n    y_title=\"Survival Probability\",\n    show_dots=False,\n    stroke=True,\n    fill=False,\n    show_x_guides=False,\n    show_y_guides=False,\n    x_label_rotation=0,\n    truncate_legend=-1,\n    legend_at_bottom=True,\n    show_legend=True,\n    range=(0, 1.05),\n    include_x_axis=True,\n    margin=80,\n    spacing=50,\n    explicit_size=True,\n)\n\n# Add CI bands first (background, filled polygons for shaded effect)\n# Don't show in legend to reduce clutter\nchart.add(\"Standard 95% CI\", ci_band_std, fill=True, stroke=False, show_dots=False, show_in_legend=False)\nchart.add(\"Experimental 95% CI\", ci_band_exp, fill=True, stroke=False, show_dots=False, show_in_legend=False)\n\n# Add main survival curves on top (solid lines with higher stroke width)\nchart.add(\"Standard Treatment\", step_std, stroke_style={\"width\": 7}, fill=False)\nchart.add(\"Experimental Treatment\", step_exp, stroke_style={\"width\": 7}, fill=False)\n\n# Add censored tick marks (vertical strokes on the curve) - don't show in legend\nchart.add(\n    \"Censored (Standard)\",\n    censored_ticks_std,\n    stroke_style={\"width\": 4},\n    show_dots=False,\n    allow_interruptions=True,\n    show_in_legend=False,\n)\nchart.add(\n    \"Censored (Experimental)\",\n    censored_ticks_exp,\n    stroke_style={\"width\": 4},\n    show_dots=False,\n    allow_interruptions=True,\n    show_in_legend=False,\n)\n\n# Save outputs\nchart.render_to_file(f\"plot-{THEME}.html\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n"}