Kaplan-Meier Survival Plot — Pygal

A Kaplan-Meier survival plot visualizes the probability of survival (or event-free time) over a time period using a step function. It is the standard method for estimating survival functions from time-to-event data, handling censored observations where the event has not yet occurred. The plot shows how survival probability decreases over time, with optional confidence intervals and comparison between groups.

Kaplan-Meier Survival Plot rendered with Pygal

Python source (Pygal)

""" anyplot.ai
survival-kaplan-meier: Kaplan-Meier Survival Plot
Library: pygal 3.1.0 | Python 3.13.13
Quality: 84/100 | Updated: 2026-05-11
"""

import os

import numpy as np
import pygal
from pygal.style import Style


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Okabe-Ito palette (first series = brand green, second = vermillion)
BRAND = "#009E73"
SECONDARY = "#C475FD"

# Set seed for reproducibility
np.random.seed(42)

# Generate synthetic clinical trial survival data
# Two treatment groups: Standard (control) and Experimental (new treatment)
n_per_group = 80

# Standard treatment group - shorter median survival
survival_times_standard = np.concatenate(
    [np.random.exponential(scale=18, size=50), np.random.exponential(scale=8, size=30)]
)
censored_standard = np.random.binomial(1, 0.25, size=n_per_group)
event_standard = 1 - censored_standard

# Experimental treatment group - longer median survival
survival_times_experimental = np.concatenate(
    [np.random.exponential(scale=28, size=55), np.random.exponential(scale=12, size=25)]
)
censored_experimental = np.random.binomial(1, 0.30, size=n_per_group)
event_experimental = 1 - censored_experimental

# Calculate Kaplan-Meier for Standard treatment
order_std = np.argsort(survival_times_standard)
times_std_sorted = survival_times_standard[order_std]
events_std_sorted = event_standard[order_std]
censored_std_sorted = censored_standard[order_std]

unique_times_std = np.unique(times_std_sorted[events_std_sorted == 1])
survival_std = [1.0]
time_points_std = [0.0]
n_at_risk_std = len(times_std_sorted)

for t in unique_times_std:
    d = np.sum((times_std_sorted == t) & (events_std_sorted == 1))
    if len(time_points_std) > 1:
        prev_t = time_points_std[-1]
        lost = np.sum((times_std_sorted > prev_t) & (times_std_sorted < t))
        n_at_risk_std -= lost
    if n_at_risk_std > 0:
        s = survival_std[-1] * (1 - d / n_at_risk_std)
    else:
        s = survival_std[-1]
    time_points_std.append(t)
    survival_std.append(s)
    n_at_risk_std -= d

time_std = np.array(time_points_std)
surv_std = np.array(survival_std)

# Calculate Kaplan-Meier for Experimental treatment
order_exp = np.argsort(survival_times_experimental)
times_exp_sorted = survival_times_experimental[order_exp]
events_exp_sorted = event_experimental[order_exp]
censored_exp_sorted = censored_experimental[order_exp]

unique_times_exp = np.unique(times_exp_sorted[events_exp_sorted == 1])
survival_exp = [1.0]
time_points_exp = [0.0]
n_at_risk_exp = len(times_exp_sorted)

for t in unique_times_exp:
    d = np.sum((times_exp_sorted == t) & (events_exp_sorted == 1))
    if len(time_points_exp) > 1:
        prev_t = time_points_exp[-1]
        lost = np.sum((times_exp_sorted > prev_t) & (times_exp_sorted < t))
        n_at_risk_exp -= lost
    if n_at_risk_exp > 0:
        s = survival_exp[-1] * (1 - d / n_at_risk_exp)
    else:
        s = survival_exp[-1]
    time_points_exp.append(t)
    survival_exp.append(s)
    n_at_risk_exp -= d

time_exp = np.array(time_points_exp)
surv_exp = np.array(survival_exp)

# Create step function data for pygal XY chart
step_std = []
for i in range(len(time_std)):
    if i > 0:
        step_std.append((float(time_std[i]), float(surv_std[i - 1])))
    step_std.append((float(time_std[i]), float(surv_std[i])))

step_exp = []
for i in range(len(time_exp)):
    if i > 0:
        step_exp.append((float(time_exp[i]), float(surv_exp[i - 1])))
    step_exp.append((float(time_exp[i]), float(surv_exp[i])))

# Calculate 95% confidence intervals using Greenwood's formula
# Standard treatment CI
var_std = []
n_risk_std = len(times_std_sorted)
cumvar_std = 0.0
for i in range(len(time_std)):
    if i == 0:
        var_std.append(0.0)
    else:
        mask = times_std_sorted == time_std[i]
        d = np.sum(mask & (events_std_sorted == 1))
        if n_risk_std > 0 and d > 0:
            cumvar_std += d / (n_risk_std * (n_risk_std - d + 0.001))
        var_std.append(cumvar_std)
        n_risk_std -= np.sum(mask)

se_std = surv_std * np.sqrt(var_std)
ci_upper_std = np.minimum(surv_std + 1.96 * se_std, 1.0)
ci_lower_std = np.maximum(surv_std - 1.96 * se_std, 0.0)

# Experimental treatment CI
var_exp = []
n_risk_exp = len(times_exp_sorted)
cumvar_exp = 0.0
for i in range(len(time_exp)):
    if i == 0:
        var_exp.append(0.0)
    else:
        mask = times_exp_sorted == time_exp[i]
        d = np.sum(mask & (events_exp_sorted == 1))
        if n_risk_exp > 0 and d > 0:
            cumvar_exp += d / (n_risk_exp * (n_risk_exp - d + 0.001))
        var_exp.append(cumvar_exp)
        n_risk_exp -= np.sum(mask)

se_exp = surv_exp * np.sqrt(var_exp)
ci_upper_exp = np.minimum(surv_exp + 1.96 * se_exp, 1.0)
ci_lower_exp = np.maximum(surv_exp - 1.96 * se_exp, 0.0)

# Create CI band step data - upper bound then back via lower for fill effect
ci_band_std = []
for i in range(len(time_std)):
    if i > 0:
        ci_band_std.append((float(time_std[i]), float(ci_upper_std[i - 1])))
    ci_band_std.append((float(time_std[i]), float(ci_upper_std[i])))
# Trace back along lower bound (reversed)
for i in range(len(time_std) - 1, -1, -1):
    ci_band_std.append((float(time_std[i]), float(ci_lower_std[i])))
    if i > 0:
        ci_band_std.append((float(time_std[i]), float(ci_lower_std[i - 1])))

ci_band_exp = []
for i in range(len(time_exp)):
    if i > 0:
        ci_band_exp.append((float(time_exp[i]), float(ci_upper_exp[i - 1])))
    ci_band_exp.append((float(time_exp[i]), float(ci_upper_exp[i])))
# Trace back along lower bound (reversed)
for i in range(len(time_exp) - 1, -1, -1):
    ci_band_exp.append((float(time_exp[i]), float(ci_lower_exp[i])))
    if i > 0:
        ci_band_exp.append((float(time_exp[i]), float(ci_lower_exp[i - 1])))

# Get censored observation points - create vertical tick marks
# Each tick is a short vertical line segment at the survival probability
tick_height = 0.03  # Height of censored tick marks
censored_times_std = times_std_sorted[censored_std_sorted == 1]
censored_ticks_std = []
for ct in censored_times_std:
    idx = np.searchsorted(time_std, ct, side="right") - 1
    idx = max(0, min(idx, len(surv_std) - 1))
    y_val = float(surv_std[idx])
    # Each tick is a pair of points (vertical line segment)
    censored_ticks_std.append((float(ct), y_val - tick_height / 2))
    censored_ticks_std.append((float(ct), y_val + tick_height / 2))
    censored_ticks_std.append((None, None))  # Break to create separate tick marks

censored_times_exp = times_exp_sorted[censored_exp_sorted == 1]
censored_ticks_exp = []
for ct in censored_times_exp:
    idx = np.searchsorted(time_exp, ct, side="right") - 1
    idx = max(0, min(idx, len(surv_exp) - 1))
    y_val = float(surv_exp[idx])
    censored_ticks_exp.append((float(ct), y_val - tick_height / 2))
    censored_ticks_exp.append((float(ct), y_val + tick_height / 2))
    censored_ticks_exp.append((None, None))

# Custom style with theme-adaptive colors
brand_ci_light = "rgba(0, 158, 115, 0.15)"  # Brand green, light alpha
brand_ci_dark = "rgba(0, 158, 115, 0.15)"
secondary_ci_light = "rgba(196, 117, 253, 0.15)"  # Secondary orange, light alpha
secondary_ci_dark = "rgba(196, 117, 253, 0.15)"

# CI band colors match data colors with transparency
ci_colors = (
    brand_ci_light if THEME == "light" else brand_ci_dark,
    secondary_ci_light if THEME == "light" else secondary_ci_dark,
    BRAND,
    SECONDARY,
    BRAND,
    SECONDARY,
)

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=ci_colors,
    title_font_size=28,
    label_font_size=22,
    major_label_font_size=18,
    legend_font_size=16,
    value_font_size=14,
    stroke_width=3,
    guide_stroke_dasharray="0",
    major_guide_stroke_dasharray="0",
    font_family="sans-serif",
)

# Create XY chart for survival curves
chart = pygal.XY(
    width=4800,
    height=2700,
    style=custom_style,
    title="survival-kaplan-meier · pygal · anyplot.ai",
    x_title="Time (Months)",
    y_title="Survival Probability",
    show_dots=False,
    stroke=True,
    fill=False,
    show_x_guides=False,
    show_y_guides=False,
    x_label_rotation=0,
    truncate_legend=-1,
    legend_at_bottom=True,
    show_legend=True,
    range=(0, 1.05),
    include_x_axis=True,
    margin=80,
    spacing=50,
    explicit_size=True,
)

# Add CI bands first (background, filled polygons for shaded effect)
# Don't show in legend to reduce clutter
chart.add("Standard 95% CI", ci_band_std, fill=True, stroke=False, show_dots=False, show_in_legend=False)
chart.add("Experimental 95% CI", ci_band_exp, fill=True, stroke=False, show_dots=False, show_in_legend=False)

# Add main survival curves on top (solid lines with higher stroke width)
chart.add("Standard Treatment", step_std, stroke_style={"width": 7}, fill=False)
chart.add("Experimental Treatment", step_exp, stroke_style={"width": 7}, fill=False)

# Add censored tick marks (vertical strokes on the curve) - don't show in legend
chart.add(
    "Censored (Standard)",
    censored_ticks_std,
    stroke_style={"width": 4},
    show_dots=False,
    allow_interruptions=True,
    show_in_legend=False,
)
chart.add(
    "Censored (Experimental)",
    censored_ticks_exp,
    stroke_style={"width": 4},
    show_dots=False,
    allow_interruptions=True,
    show_in_legend=False,
)

# Save outputs
chart.render_to_file(f"plot-{THEME}.html")
chart.render_to_png(f"plot-{THEME}.png")

Part of Kaplan-Meier Survival Plot on anyplot.ai.

Other implementations