Kaplan-Meier Survival Plot — Makie.jl

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 Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# survival-kaplan-meier: Kaplan-Meier Survival Plot
# Library: makie 0.21.9 | Julia 1.11.9
# Quality: 92/100 | Created: 2026-09-09

using CairoMakie
using Colors
using Random

Random.seed!(42)

# Theme tokens
const THEME       = get(ENV, "ANYPLOT_THEME", "light")
const PAGE_BG     = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
const ELEVATED_BG = THEME == "light" ? colorant"#FFFDF6" : colorant"#242420"
const INK         = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
const INK_SOFT    = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"

const IMPRINT_PALETTE = [
    colorant"#009E73",
    colorant"#C475FD",
    colorant"#4467A3",
    colorant"#BD8233",
    colorant"#AE3030",
    colorant"#2ABCCD",
    colorant"#954477",
    colorant"#99B314",
]

# Data — subscription retention: Free vs Premium tier time-to-churn (months)
n_free    = 220
n_premium = 220
study_end = 36.0

churn_free     = 14.0 .* (-log.(rand(n_free))) .^ (1 / 1.3)
churn_premium  = 26.0 .* (-log.(rand(n_premium))) .^ (1 / 1.3)
censor_free    = min.(study_end, 40.0 .* (-log.(rand(n_free))))
censor_premium = min.(study_end, 40.0 .* (-log.(rand(n_premium))))

time_free     = min.(churn_free, censor_free)
event_free    = Int.(churn_free .<= censor_free)
time_premium  = min.(churn_premium, censor_premium)
event_premium = Int.(churn_premium .<= censor_premium)

# Kaplan-Meier estimator with Greenwood's formula for the standard error
function kaplan_meier(time, event)
    event_times = sort(unique(time[event .== 1]))
    survival = Float64[]
    stderr   = Float64[]

    s = 1.0
    greenwood_sum = 0.0
    for et in event_times
        n_risk = count(>=(et), time)
        d      = count(==(et), time[event .== 1])
        s *= (1 - d / n_risk)
        push!(survival, s)
        if n_risk > d
            greenwood_sum += d / (n_risk * (n_risk - d))
        end
        push!(stderr, s * sqrt(greenwood_sum))
    end

    censor_times = time[event .== 0]
    return event_times, survival, stderr, censor_times
end

# Step-function coordinates for the curve and its 95% confidence band
function km_step(event_times, survival, stderr, t_start, t_end)
    xs, ys, lo, hi = Float64[t_start], Float64[1.0], Float64[1.0], Float64[1.0]
    for i in eachindex(event_times)
        push!(xs, event_times[i]); push!(ys, ys[end]); push!(lo, lo[end]); push!(hi, hi[end])
        push!(xs, event_times[i]); push!(ys, survival[i])
        push!(lo, clamp(survival[i] - 1.96 * stderr[i], 0.0, 1.0))
        push!(hi, clamp(survival[i] + 1.96 * stderr[i], 0.0, 1.0))
    end
    push!(xs, t_end); push!(ys, ys[end]); push!(lo, lo[end]); push!(hi, hi[end])
    return xs, ys, lo, hi
end

survival_at(event_times, survival, t) = isempty(event_times) || t < event_times[1] ? 1.0 :
    survival[findlast(<=(t), event_times)]

et_free, surv_free, se_free, cens_free = kaplan_meier(time_free, event_free)
et_prem, surv_prem, se_prem, cens_prem = kaplan_meier(time_premium, event_premium)

xs_free, ys_free, lo_free, hi_free = km_step(et_free, surv_free, se_free, 0.0, study_end)
xs_prem, ys_prem, lo_prem, hi_prem = km_step(et_prem, surv_prem, se_prem, 0.0, study_end)

cens_y_free = [survival_at(et_free, surv_free, t) for t in cens_free]
cens_y_prem = [survival_at(et_prem, surv_prem, t) for t in cens_prem]

median_idx_free = findfirst(<=(0.5), surv_free)
median_idx_prem = findfirst(<=(0.5), surv_prem)
median_free = median_idx_free === nothing ? nothing : et_free[median_idx_free]
median_prem = median_idx_prem === nothing ? nothing : et_prem[median_idx_prem]

# Log-rank test (Mantel-Haenszel chi-square, 1 df) comparing the two groups
combined_event_times = sort(unique(vcat(et_free, et_prem)))
observed_free, expected_free, variance_sum = 0.0, 0.0, 0.0
for t in combined_event_times
    n1 = count(>=(t), time_free)
    n2 = count(>=(t), time_premium)
    d1 = count(==(t), time_free[event_free .== 1])
    d2 = count(==(t), time_premium[event_premium .== 1])
    n, d = n1 + n2, d1 + d2
    if n > 1 && d > 0
        global observed_free += d1
        global expected_free += d * n1 / n
        global variance_sum += d * (n1 / n) * (n2 / n) * ((n - d) / (n - 1))
    end
end
logrank_chi2 = (observed_free - expected_free)^2 / variance_sum

# Standard normal CDF (Abramowitz & Stegun 26.2.17) — avoids a SpecialFunctions dependency
function normal_cdf(x)
    z = abs(x)
    t = 1 / (1 + 0.2316419 * z)
    poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 +
           t * (-1.821255978 + t * 1.330274429))))
    phi = 1 - poly * exp(-z^2 / 2) / sqrt(2π)
    return x >= 0 ? phi : 1 - phi
end
logrank_p = 2 * (1 - normal_cdf(sqrt(logrank_chi2)))

# Number-at-risk table gridpoints
risk_times = collect(0.0:6.0:study_end)
risk_free  = [count(>=(t), time_free) for t in risk_times]
risk_prem  = [count(>=(t), time_premium) for t in risk_times]

# Title (scaled fontsize — descriptive prefix pushes past the 67-char baseline)
title_str  = "Subscription Retention · survival-kaplan-meier · julia · makie · anyplot.ai"
title_size = max(14, round(Int, 20 * min(1.0, 67.0 / length(title_str))))

# Plot
fig = Figure(
    size            = (1600, 900),
    fontsize        = 14,
    backgroundcolor = PAGE_BG,
)

ax = Axis(
    fig[1, 1];
    title             = title_str,
    titlesize         = title_size,
    titlecolor        = INK,
    ylabel            = "Survival Probability",
    xlabelsize        = 14,
    ylabelsize        = 14,
    xticklabelsize    = 12,
    yticklabelsize    = 12,
    xlabelcolor       = INK,
    ylabelcolor       = INK,
    xticklabelcolor   = INK_SOFT,
    yticklabelcolor   = INK_SOFT,
    xtickcolor        = INK_SOFT,
    ytickcolor        = INK_SOFT,
    backgroundcolor   = PAGE_BG,
    topspinevisible   = false,
    rightspinevisible = false,
    leftspinecolor    = INK_SOFT,
    bottomspinecolor  = INK_SOFT,
    xgridcolor        = RGBAf(INK.r, INK.g, INK.b, 0.12),
    ygridcolor        = RGBAf(INK.r, INK.g, INK.b, 0.12),
    xminorgridvisible = false,
    yminorgridvisible = false,
    limits            = (0.0, study_end, 0.0, 1.05),
)

# 95% confidence bands (Greenwood's formula) — drawn first, behind the curves
band!(ax, xs_free, lo_free, hi_free;
    color = RGBAf(IMPRINT_PALETTE[1].r, IMPRINT_PALETTE[1].g, IMPRINT_PALETTE[1].b, 0.15))
band!(ax, xs_prem, lo_prem, hi_prem;
    color = RGBAf(IMPRINT_PALETTE[2].r, IMPRINT_PALETTE[2].g, IMPRINT_PALETTE[2].b, 0.15))

# Kaplan-Meier step curves
lines!(ax, xs_free, ys_free; color = IMPRINT_PALETTE[1], linewidth = 3.0, label = "Free Tier")
lines!(ax, xs_prem, ys_prem; color = IMPRINT_PALETTE[2], linewidth = 3.0, label = "Premium Tier")

# Censoring marks
scatter!(ax, cens_free, cens_y_free;
    marker = :vline, markersize = 14, color = IMPRINT_PALETTE[1], strokewidth = 0)
scatter!(ax, cens_prem, cens_y_prem;
    marker = :vline, markersize = 14, color = IMPRINT_PALETTE[2], strokewidth = 0)

# Median survival reference lines
hlines!(ax, [0.5]; color = INK_SOFT, linewidth = 1.0, linestyle = :dot)
median_free !== nothing && vlines!(ax, [median_free]; color = IMPRINT_PALETTE[1], linewidth = 1.0, linestyle = :dash)
median_prem !== nothing && vlines!(ax, [median_prem]; color = IMPRINT_PALETTE[2], linewidth = 1.0, linestyle = :dash)

median_free_str = median_free === nothing ? "not reached" : "$(round(median_free; digits = 1)) mo"
median_prem_str = median_prem === nothing ? "not reached" : "$(round(median_prem; digits = 1)) mo"
p_str = logrank_p < 0.001 ? "p < 0.001" : "p = $(round(logrank_p; digits = 3))"

text!(ax,
    "Median survival — Free: $(median_free_str) · Premium: $(median_prem_str)\nLog-rank test: $(p_str)";
    position = Point2f(study_end * 0.97, 0.8),
    align    = (:right, :top),
    color    = INK,
    fontsize = 13,
)

axislegend(ax;
    position        = :rt,
    labelsize       = 12,
    framevisible    = true,
    framecolor      = INK_SOFT,
    backgroundcolor = ELEVATED_BG,
    labelcolor      = INK,
)

# Number-at-risk table
ax_risk = Axis(
    fig[2, 1];
    backgroundcolor     = PAGE_BG,
    limits              = (0.0, study_end, 0.0, 2.0),
    xlabel              = "Time Since Signup (months)",
    xlabelsize          = 14,
    xlabelcolor         = INK,
    xticklabelsize      = 12,
    xticklabelcolor     = INK_SOFT,
    xtickcolor          = INK_SOFT,
    yticks              = ([0.5, 1.5], ["Premium", "Free"]),
    yticklabelsize      = 12,
    yticklabelcolor     = INK_SOFT,
    yticksvisible       = false,
    topspinevisible     = false,
    rightspinevisible   = false,
    leftspinevisible    = false,
    bottomspinecolor    = INK_SOFT,
)
Label(fig[2, 1, Top()], "Number at risk";
    fontsize = 12, color = INK_SOFT, halign = :left, padding = (0, 0, 4, 0))

for (i, t) in enumerate(risk_times)
    halign = i == 1 ? :left : i == length(risk_times) ? :right : :center
    text!(ax_risk, string(risk_free[i]);
        position = Point2f(t, 1.5), align = (halign, :center), color = IMPRINT_PALETTE[1], fontsize = 12)
    text!(ax_risk, string(risk_prem[i]);
        position = Point2f(t, 0.5), align = (halign, :center), color = IMPRINT_PALETTE[2], fontsize = 12)
end

rowsize!(fig.layout, 2, Relative(0.16))

# Save
save("plot-$(THEME).png", fig; px_per_unit = 2)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/survival-kaplan-meier/makie/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "survival-kaplan-meier",
  "language": "julia",
  "library": "makie",
  "page": "https://anyplot.ai/survival-kaplan-meier/julia/makie",
  "hub": "https://anyplot.ai/survival-kaplan-meier",
  "code_json": "https://api.anyplot.ai/specs/survival-kaplan-meier/makie/code",
  "spec_json": "https://api.anyplot.ai/specs/survival-kaplan-meier",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/survival-kaplan-meier/julia/makie/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/survival-kaplan-meier/julia/makie/plot-dark.png",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations