Kaplan-Meier Survival Plot — lets-plot

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 lets-plot

Python source (lets-plot)

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

import os
import shutil

import numpy as np
import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_line,
    element_rect,
    element_text,
    geom_point,
    geom_ribbon,
    geom_step,
    ggplot,
    ggsave,
    ggsize,
    labs,
    scale_color_manual,
    scale_fill_manual,
    theme,
    theme_minimal,
)


LetsPlot.setup_html()

# Theme tokens
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"

# Okabe-Ito palette (brand color #009E73 is first series)
IMPRINT = ["#009E73", "#C475FD"]

# Data - Simulated clinical trial survival data for two treatment groups
np.random.seed(42)

n_per_group = 80


def generate_survival_data(n, hazard_rate, group_name):
    """Generate survival data with exponential distribution."""
    times = np.random.exponential(scale=1 / hazard_rate, size=n)
    times = np.clip(times, 0, 36)  # Max follow-up 36 months
    censored = times >= 36
    times[censored] = 36
    event = (~censored).astype(int)
    # Add random censoring (20% of non-terminal events)
    random_censor = np.random.random(n) < 0.2
    event[random_censor] = 0
    return pd.DataFrame({"time": times, "event": event, "group": group_name})


# Treatment group (lower hazard = better survival)
treatment = generate_survival_data(n_per_group, hazard_rate=0.04, group_name="Treatment")
# Control group (higher hazard = worse survival)
control = generate_survival_data(n_per_group, hazard_rate=0.08, group_name="Control")
df = pd.concat([treatment, control], ignore_index=True)


# Kaplan-Meier estimator function
def kaplan_meier(time, event):
    """Compute Kaplan-Meier survival curve with confidence intervals."""
    df_km = pd.DataFrame({"time": time, "event": event}).sort_values("time")
    unique_times = np.sort(df_km["time"].unique())
    n_at_risk = len(df_km)
    survival = 1.0
    results = [{"time": 0, "survival": 1.0, "ci_lower": 1.0, "ci_upper": 1.0, "n_at_risk": n_at_risk}]
    var_sum = 0

    for t in unique_times:
        at_time = df_km[df_km["time"] == t]
        d = at_time["event"].sum()  # Number of events
        n = n_at_risk  # Number at risk
        if n > 0 and d > 0:
            survival *= 1 - d / n
            var_sum += d / (n * (n - d)) if n > d else 0
        # Greenwood's formula for variance
        se = survival * np.sqrt(var_sum) if var_sum > 0 else 0
        ci_lower = max(0, survival - 1.96 * se)
        ci_upper = min(1, survival + 1.96 * se)
        results.append({"time": t, "survival": survival, "ci_lower": ci_lower, "ci_upper": ci_upper, "n_at_risk": n})
        n_at_risk -= len(at_time)

    return pd.DataFrame(results)


# Compute Kaplan-Meier for each group
km_treatment = kaplan_meier(treatment["time"], treatment["event"])
km_treatment["group"] = "Treatment"
km_control = kaplan_meier(control["time"], control["event"])
km_control["group"] = "Control"
km_data = pd.concat([km_treatment, km_control], ignore_index=True)

# Find censored observations for tick marks
censored_treatment = treatment[treatment["event"] == 0].copy()
censored_control = control[control["event"] == 0].copy()


def get_survival_at_time(km_df, t):
    """Get survival probability at a given time."""
    km_df = km_df.sort_values("time")
    idx = km_df[km_df["time"] <= t].index
    if len(idx) == 0:
        return 1.0
    return km_df.loc[idx[-1], "survival"]


censored_treatment["survival"] = censored_treatment["time"].apply(lambda t: get_survival_at_time(km_treatment, t))
censored_treatment["group"] = "Treatment"
censored_control["survival"] = censored_control["time"].apply(lambda t: get_survival_at_time(km_control, t))
censored_control["group"] = "Control"
censored_data = pd.concat([censored_treatment, censored_control], ignore_index=True)

# Plot
plot = (
    ggplot()
    # Confidence interval ribbons
    + geom_ribbon(aes(x="time", ymin="ci_lower", ymax="ci_upper", fill="group"), data=km_data, alpha=0.2)
    # Step functions for survival curves
    + geom_step(aes(x="time", y="survival", color="group"), data=km_data, size=1.5)
    # Censored observation tick marks
    + geom_point(
        aes(x="time", y="survival", color="group"),
        data=censored_data,
        shape=3,  # Plus sign for censoring ticks
        size=4,
        stroke=2,
    )
    + scale_color_manual(values=IMPRINT)
    + scale_fill_manual(values=IMPRINT)
    + labs(
        x="Time (months)",
        y="Survival Probability",
        title="survival-kaplan-meier · letsplot · pyplots.ai",
        color="Group",
        fill="Group",
    )
    + theme_minimal()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        panel_grid_major=element_line(color=INK_MUTED, size=0.3),
        axis_title=element_text(size=20, color=INK),
        axis_text=element_text(size=16, color=INK_SOFT),
        axis_line=element_line(color=INK_SOFT, size=0.5),
        plot_title=element_text(size=24, color=INK),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_text=element_text(size=16, color=INK_SOFT),
        legend_title=element_text(size=18, color=INK),
    )
    + ggsize(1600, 900)
)

# Save
ggsave(plot, f"plot-{THEME}.png", scale=3)
ggsave(plot, f"plot-{THEME}.html")

# lets-plot creates a subdirectory; move files to current directory
if os.path.exists("lets-plot-images"):
    for file in os.listdir("lets-plot-images"):
        shutil.move(f"lets-plot-images/{file}", file)
    os.rmdir("lets-plot-images")

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

Other implementations