Menu

ClinicalLive Code Editor
146 researchers ran this analysis this month

Kaplan-Meier Survival Analysis in Python

Technique overview

Estimate survival functions from censored time-to-event data with confidence intervals and log-rank tests for group comparison.

Survival analysis addresses a class of data that standard statistical methods cannot handle correctly: time-to-event outcomes where not all subjects have yet experienced the event by the end of the study. Patients who are still alive at last follow-up, participants who withdrew from a trial, or electronic components still functioning when the test ended all contribute partial information known as censored observations. Discarding these observations would bias the analysis toward shorter survival times. The Kaplan-Meier (product-limit) estimator is the standard non-parametric method for incorporating censored data, producing a step-function estimate of the survival probability S(t) over time. It is ubiquitous in clinical oncology, device reliability engineering, and epidemiology, and pairing it with the log-rank test allows rigorous group comparison. This page demonstrates the complete Python workflow using numpy and scipy - no third-party survival analysis packages required.

Key points

  • Estimate survival functions from censored time-to-event data with confidence intervals and log-rank tests for group comparison.
  • Survival analysis addresses a class of data that standard statistical methods cannot handle correctly: time-to-event outcomes where not all subjects have yet experienced the event by the end of the study.
  • Patients who are still alive at last follow-up, participants who withdrew from a trial, or electronic components still functioning when the test ended all contribute partial information known as censored observations.
  • Discarding these observations would bias the analysis toward shorter survival times.
lifelinesscipynumpymatplotlib

Example Visualization

Review the example first, then use the live editor below to run and customize the full workflow.

Mathematical Foundation

Survival analysis addresses a class of data that standard statistical methods cannot handle correctly: time-to-event outcomes where not all subjects have yet experienced the event by the end of the study.

S(t) =tᵢ ≤ tni − dini

Equation

S(t) = product_{ti <= t} [ (ni - di) / ni ]

Parameter breakdown

S(t)Estimated survival probability at time t (probability of surviving beyond t)
tiEach unique event time in the dataset
niNumber of subjects at risk (alive and uncensored) just before time ti
diNumber of events (deaths, failures) at time ti
productTaken over all event times up to and including t

When to use this technique

Use Kaplan-Meier analysis whenever you have time-to-event data with censoring. It is appropriate for clinical endpoints (overall survival, progression-free survival), reliability endpoints (time to failure), and any study where subjects enter and exit the observation window at different times. For adjusted estimates that control for covariates, use the Cox proportional hazards model as an extension.

Apply This Technique Now

Run this analysis workflow with AI in seconds. Use the prepared technique prompt or bring your own dataset.

View example prompt
Example AI Prompt

"Plot Kaplan-Meier survival curves for my censored time-to-event data, add 95% confidence intervals, perform a log-rank test for group comparison, and annotate the median survival time for each group"

How to apply this technique in 30 seconds

1

Upload Data

Upload your CSV or Excel file in Analyze and keep your column names as-is.

2

Generate

Run the example prompt and let AI generate this technique automatically.

3

Refine and Export

Adjust code or prompt, then export publication-ready figures.

Implementation Code

The core data processing logic. Copy this block and replace the sample data with your measurements.

import numpy as np
from scipy import stats as scipy_stats

def kaplan_meier(durations, event_observed):
    """Compute Kaplan-Meier survival estimate via the product-limit formula."""
    durations = np.asarray(durations, dtype=float)
    event_observed = np.asarray(event_observed, dtype=bool)
    order = np.argsort(durations)
    durations, event_observed = durations[order], event_observed[order]
    n = len(durations)
    times, survival = [0.0], [1.0]
    n_at_risk = n
    i = 0
    while i < n:
        t = durations[i]
        j = i
        while j < n and durations[j] == t:
            j += 1
        d = int(event_observed[i:j].sum())
        if d > 0:
            times.append(t)
            survival.append(survival[-1] * (1.0 - d / n_at_risk))
        n_at_risk -= (j - i)
        i = j
    return np.array(times), np.array(survival)

def km_median(times, survival):
    """First time at which S(t) <= 0.5 (median survival)."""
    idx = np.where(survival <= 0.5)[0]
    return times[idx[0]] if len(idx) > 0 else float('inf')

def log_rank_test(t1, e1, t2, e2):
    """Chi-squared log-rank test for two independent groups."""
    e1, e2 = np.asarray(e1, bool), np.asarray(e2, bool)
    event_times = np.unique(np.concatenate([t1[e1], t2[e2]]))
    O1, E1, V = 0.0, 0.0, 0.0
    for t in event_times:
        n1, n2 = int((t1 >= t).sum()), int((t2 >= t).sum())
        d1, d2 = int(((t1 == t) & e1).sum()), int(((t2 == t) & e2).sum())
        n, d = n1 + n2, d1 + d2
        if n > 1:
            E1 += n1 * d / n
            O1 += d1
            V += n1 * n2 * d * (n - d) / (n ** 2 * (n - 1))
    chi2 = (O1 - E1) ** 2 / (V + 1e-15)
    p_val = float(1 - scipy_stats.chi2.cdf(chi2, df=1))
    return chi2, p_val

# --- Two-group survival data ---
np.random.seed(42)
n_per_group = 50

durations_a = np.random.exponential(scale=18, size=n_per_group)
events_a    = np.random.binomial(1, 0.75, size=n_per_group)  # 75% observed events

durations_b = np.random.exponential(scale=10, size=n_per_group)
events_b    = np.random.binomial(1, 0.80, size=n_per_group)  # 80% observed events

t_a, s_a = kaplan_meier(durations_a, events_a)
t_b, s_b = kaplan_meier(durations_b, events_b)

print(f"Median survival - Group A: {km_median(t_a, s_a):.2f} months")
print(f"Median survival - Group B: {km_median(t_b, s_b):.2f} months")

chi2, p_value = log_rank_test(durations_a, events_a, durations_b, events_b)
print(f"\nLog-rank test:")
print(f"  Test statistic : {chi2:.4f}")
print(f"  p-value        : {p_value:.4f}")
sig = '***' if p_value < 0.001 else ('**' if p_value < 0.01 else ('*' if p_value < 0.05 else 'ns'))
print(f"  Significance   : {sig}")

Visualization Code

Complete matplotlib code for a publication-ready figure. Copy, paste into your notebook, and adjust labels to match your data.

import numpy as np
import matplotlib.pyplot as plt
from scipy import stats as scipy_stats

def kaplan_meier(durations, event_observed):
    durations = np.asarray(durations, dtype=float)
    event_observed = np.asarray(event_observed, dtype=bool)
    order = np.argsort(durations)
    durations, event_observed = durations[order], event_observed[order]
    n = len(durations)
    times, survival = [0.0], [1.0]
    n_at_risk = n
    i = 0
    while i < n:
        t = durations[i]
        j = i
        while j < n and durations[j] == t:
            j += 1
        d = int(event_observed[i:j].sum())
        if d > 0:
            times.append(t)
            survival.append(survival[-1] * (1.0 - d / n_at_risk))
        n_at_risk -= (j - i)
        i = j
    return np.array(times), np.array(survival)

def km_median(times, survival):
    idx = np.where(survival <= 0.5)[0]
    return times[idx[0]] if len(idx) > 0 else float('inf')

def log_rank_p(t1, e1, t2, e2):
    e1, e2 = np.asarray(e1, bool), np.asarray(e2, bool)
    event_times = np.unique(np.concatenate([t1[e1], t2[e2]]))
    O1, E1, V = 0.0, 0.0, 0.0
    for t in event_times:
        n1, n2 = int((t1 >= t).sum()), int((t2 >= t).sum())
        d1, d2 = int(((t1 == t) & e1).sum()), int(((t2 == t) & e2).sum())
        n, d = n1 + n2, d1 + d2
        if n > 1:
            E1 += n1 * d / n
            O1 += d1
            V += n1 * n2 * d * (n - d) / (n ** 2 * (n - 1))
    chi2 = (O1 - E1) ** 2 / (V + 1e-15)
    return float(1 - scipy_stats.chi2.cdf(chi2, df=1))

# --- Data ---
np.random.seed(42)
n = 50
durations_a = np.random.exponential(scale=18, size=n)
events_a    = np.random.binomial(1, 0.75, size=n)
durations_b = np.random.exponential(scale=10, size=n)
events_b    = np.random.binomial(1, 0.80, size=n)

t_a, s_a = kaplan_meier(durations_a, events_a)
t_b, s_b = kaplan_meier(durations_b, events_b)

p_val = log_rank_p(durations_a, events_a, durations_b, events_b)
sig = '***' if p_val < 0.001 else ('**' if p_val < 0.01 else ('*' if p_val < 0.05 else 'ns'))

# --- Figure ---
fig, ax = plt.subplots(figsize=(8, 5))
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)

ax.step(t_a, s_a, where='post', color='#9240ff', linewidth=2, label='Group A (control)')
ax.step(t_b, s_b, where='post', color='#888888', linewidth=2, label='Group B (treatment)')

for t_km, s_km, color, y_pos in [(t_a, s_a, '#9240ff', 0.55), (t_b, s_b, '#888888', 0.45)]:
    med = km_median(t_km, s_km)
    if not np.isinf(med):
        ax.axvline(med, color=color, linestyle=':', linewidth=1.2, alpha=0.7)
        ax.text(med + 0.5, y_pos, f'Median = {med:.1f} mo',
                color=color, fontsize=8.5, va='center')

ax.text(0.97, 0.97,
        f'Log-rank test\np = {p_val:.4f}  {sig}',
        transform=ax.transAxes, ha='right', va='top',
        fontsize=9, color='white',
        bbox=dict(boxstyle='round', facecolor='#1a1a2e', alpha=0.7))

ax.set_xlabel('Time (months)', fontsize=12)
ax.set_ylabel('Survival Probability S(t)', fontsize=12)
ax.set_ylim(-0.05, 1.05)
ax.set_title('Kaplan-Meier Survival Curves', fontsize=14)
ax.legend(frameon=False, fontsize=10, loc='upper right',
          bbox_to_anchor=(0.97, 0.80))

plt.tight_layout()
plt.savefig('kaplan_meier_survival.png', dpi=300, bbox_inches='tight')
plt.show()

Stratified Kaplan-Meier with Three or More Groups

Clinical studies frequently stratify patients by multiple factors such as disease stage, treatment dose, or biomarker level. Plotting Kaplan-Meier curves for three or more groups simultaneously and using a multivariate log-rank test (or pairwise tests with Bonferroni correction) extends the two-group comparison to more complex study designs.

import numpy as np
import matplotlib.pyplot as plt
from scipy import stats as scipy_stats

def kaplan_meier(durations, event_observed):
    durations = np.asarray(durations, dtype=float)
    event_observed = np.asarray(event_observed, dtype=bool)
    order = np.argsort(durations)
    durations, event_observed = durations[order], event_observed[order]
    n = len(durations)
    times, survival = [0.0], [1.0]
    n_at_risk = n
    i = 0
    while i < n:
        t = durations[i]
        j = i
        while j < n and durations[j] == t:
            j += 1
        d = int(event_observed[i:j].sum())
        if d > 0:
            times.append(t)
            survival.append(survival[-1] * (1.0 - d / n_at_risk))
        n_at_risk -= (j - i)
        i = j
    return np.array(times), np.array(survival)

def multivariate_log_rank_p(all_dur, all_grp, all_ev):
    """Chi-squared log-rank test for K groups (K-1 degrees of freedom)."""
    all_dur = np.asarray(all_dur, float)
    all_ev  = np.asarray(all_ev, bool)
    unique_g = sorted(set(all_grp))
    K = len(unique_g)
    event_times = np.unique(all_dur[all_ev])
    O = np.zeros(K); E = np.zeros(K); V = np.zeros((K, K))
    for t in event_times:
        mask_r = all_dur >= t
        mask_e = (all_dur == t) & all_ev
        n_all = mask_r.sum(); d_all = mask_e.sum()
        if n_all < 2:
            continue
        n_k = np.array([mask_r[np.array(all_grp) == g].sum() for g in unique_g], float)
        d_k = np.array([mask_e[np.array(all_grp) == g].sum() for g in unique_g], float)
        O += d_k; E += n_k * d_all / n_all
        for i in range(K):
            V[i, i] += (n_k[i] * (n_all - n_k[i]) * d_all *
                        (n_all - d_all) / (n_all ** 2 * max(n_all - 1, 1)))
    diffs = (O - E)[:-1]
    Vs = V[:-1, :-1]
    try:
        chi2 = float(diffs @ np.linalg.inv(Vs) @ diffs)
    except np.linalg.LinAlgError:
        chi2 = 0.0
    return float(1 - scipy_stats.chi2.cdf(chi2, df=K - 1))

# --- Three-group survival data ---
np.random.seed(42)
n = 40
groups = {
    'Stage I (early)':    (np.random.exponential(24, n), np.random.binomial(1, 0.65, n)),
    'Stage II (mid)':     (np.random.exponential(14, n), np.random.binomial(1, 0.75, n)),
    'Stage III (late)':   (np.random.exponential(7,  n), np.random.binomial(1, 0.85, n)),
}
colors = ['#9240ff', '#888888', '#e8a020']

fig, ax = plt.subplots(figsize=(8, 5))
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)

all_dur, all_ev, all_grp = [], [], []
for (label, (dur, ev)), color in zip(groups.items(), colors):
    t_km, s_km = kaplan_meier(dur, ev)
    ax.step(t_km, s_km, where='post', color=color, linewidth=2, label=label)
    all_dur.extend(dur.tolist())
    all_ev.extend(ev.tolist())
    all_grp.extend([label] * n)

p_val = multivariate_log_rank_p(np.array(all_dur), all_grp, np.array(all_ev))
sig = '***' if p_val < 0.001 else ('**' if p_val < 0.01 else ('*' if p_val < 0.05 else 'ns'))

ax.text(0.97, 0.97,
        f'Log-rank test (3 groups)\np = {p_val:.4f}  {sig}',
        transform=ax.transAxes, ha='right', va='top', fontsize=9, color='white',
        bbox=dict(boxstyle='round', facecolor='#1a1a2e', alpha=0.7))
ax.set_xlabel('Time (months)', fontsize=12)
ax.set_ylabel('Survival Probability S(t)', fontsize=12)
ax.set_ylim(-0.05, 1.05)
ax.set_title('Stratified Kaplan-Meier Survival Curves', fontsize=14)
ax.legend(frameon=False, fontsize=9, loc='upper right')
plt.tight_layout()
plt.savefig('kaplan_meier_stratified.png', dpi=300, bbox_inches='tight')
plt.show()

Common Errors and How to Fix Them

median_survival_time returns inf

Why: The survival curve never crosses 0.5 within the observed time range. Fewer than 50% of subjects have experienced the event, so the median is not estimable from the available data.

Fix: Report the event as 'median not reached' or extend follow-up. You can still report survival probability at a specific landmark time by evaluating the step function: find the last time in the times array that is <= your landmark and read the corresponding survival value.

Log-rank test is significant but KM curves visually overlap

Why: The log-rank test is sensitive to differences at any time point, including at early or late time points where the curves may diverge temporarily. It weights all event times equally.

Fix: Check where in the follow-up the curves diverge by examining the timeline. If the difference occurs only at early times and then converges, the proportional hazards assumption may be violated - consider reporting restricted mean survival time (RMST) instead.

Frequently Asked Questions

Apply Kaplan-Meier Survival Analysis in Python to Your Data

Upload your dataset and Plotivy generates the Python code, runs the analysis, and produces a publication-ready figure.

Generate Code for This Technique

Python Libraries

lifelinesscipynumpymatplotlib

Quick Info

Domain
Clinical
Typical Audience
Clinical researchers, epidemiologists, and biostatisticians analyzing time-to-event outcomes such as patient survival, device failure, or disease recurrence

Related Chart Guides

Apply to your data

Upload a dataset and get Python code instantly

Get Started Free