NUMERICAL AUDIT · 1 NOVEMBER 2026
lifelines: the Kaplan–Meier median moves to the next event when S(t) equals q exactly
lifelines stores an exact Kaplan–Meier value of 1/2 one rounding step above 1/2, so median_survival_time_ can return the next event time; duplicating every patient, which leaves the curve unchanged, changes the answer.
Xamit Kadirbekov, Daniyal Kadirbekov · AI-assisted investigation and writing
Checked 1 November 2026: PR #1715 is open, with no review yet; not merged or released. Local regression checks and limitations documented.
Authors: Xamit Kadirbekov and Daniyal Kadirbekov (GERO). Published 1 November 2026; finding dated 30 October 2026.
Status: Checked 1 November 2026: PR #1715 is open, with no review yet; not merged or released. Local regression checks and limitations documented.
lifelines stores an exact Kaplan–Meier value of 1/2 one rounding step above 1/2, so median_survival_time_ can return the next event time; duplicating every patient, which leaves the curve unchanged, changes the answer.
Published proposal
- CamDavidsonPilon/lifelines PR #1715 — head commit
ea59b5157833124fb9bb33abf5df53d6c45aaeda, OPEN at submission on 30 October 2026.
What the function does
lifelines is an open-source Python survival-analysis library. The Kaplan–Meier (KM) curve is the share of patients still free of the event (death, relapse) at time t; it is a step function that drops only at event times. Median survival is the time when the curve reaches 50%, and it is the headline number in many oncology reports; the quartiles are the same at 75% and 25%. qth_survival_time (lifelines/utils/__init__.py) computes these and is used by KaplanMeierFitter.median_survival_time_ and percentile. Its rule is the first time with S(t) ≤ q. We checked master 7a8fc34a013ecd79fa405017b89e1697c1cc6e17; the function is the same in release 0.30.3.
The discrepancy
lifelines computes the curve as exp(cumsum(log(1 − d/n))), so an exact 1/2 can be stored as 0.5000000000000001. The exact comparison with q then skips the time where the curve reaches q and returns the next event time.
import numpy as np
from lifelines import KaplanMeierFitter
kmf = KaplanMeierFitter().fit(np.arange(1, 7), np.ones(6))
kmf.survival_function_.iloc[3, 0] # 0.5000000000000001 (exactly 1/2 in exact arithmetic)
kmf.median_survival_time_ # 4.0, expected 3.0 (first t with S(t) <= 1/2)
kmf2 = KaplanMeierFitter().fit(np.repeat(np.arange(1, 7), 2), np.ones(12))
kmf2.median_survival_time_ # 3.0 — same curve, different answer
| Uncensored events at times 1…n | lifelines median | Library's own rule, exact arithmetic |
|---|---|---|
| n = 6 | 4 | 3 |
| n = 8 | 5 | 4 |
| n = 14 | 8 | 7 |
| n = 18 | 10 | 9 |
| n = 4, 10, 12 | 2, 5, 6 | 2, 5, 6 (rounding happened to fall the right way) |
A censored 18-patient example in months, where KM is exactly 1/2 at month 12 and the next event is at month 16: lifelines reports a median of 16, its own rule gives 12, and R and SAS, which take the midpoint of a flat step, give 14. In a simulation of 1,500 small datasets (10–60 subjects, integer months, random censoring), 377 datasets had an exact flat step at 1/4, 1/2 or 3/4; lifelines was wrong in 189 of 4,500 quartiles, and the median was wrong in 65 datasets (about 4%), always later.
Reference and why this is not a convention
The reference is lifelines' own rule (first t with S(t) ≤ q) evaluated on KM products in exact fractions.Fraction arithmetic. Choosing between ≤, < and the midpoint is a convention; here the rule is fixed, and the answer depends on how the 16th decimal digit rounded. Entering every patient twice leaves the KM curve exactly the same and changes the reported median. R's survival::quantile.survfit compares with a tolerance of sqrt(.Machine$double.eps) for this reason.
Fix and tests
- Compare against q + sqrt(eps) instead of q. Genuine curve steps are many orders of magnitude larger than this tolerance.
- New parametrised test
test_km_median_with_exact_half_does_not_depend_on_rounding(n = 4, 6, 8, 12, 14, 18, 22, 26): fails for 7 of 8 cases before the fix, passes after.lifelines/tests/utils/test_utils.py: 97 passed. In a local run of the utils, statistics and plotting tests: 140 passed, 73 skipped (132 passed before, plus the 8 new cases).
Upstream status
As of 1 November 2026, PR #1715 is open, with no review or comment. Nothing has been merged or released. It is unrelated to the open PRs #1713 (the DataFrame branch of the same function) and #1705 (round-off at the end of the curve).
Limits
The log-rank test is not affected; reported medians and quartiles are. The scale is shown only on simulated small samples; in large samples an exact flat step at 1/2 is rarer. No patient data were used, and we make no claim about errors in published studies. Novelty rests on a bounded GitHub search of issues and PRs and the recent file history; it is not an exhaustive priority claim. The same tie problem, in the opposite direction, is reported separately for statsmodels.
The example was re-run in a separate session on 30 October before submission. No reproduction archive is attached to this page yet.
Attribution and license
AI tools (Claude) helped find the cases, build the reference calculations and draft the patches and tests; the examples were re-run independently before submission. Xamit Kadirbekov and Daniyal Kadirbekov are the report authors and reviewed the submitted diff; upstream software remains credited to its developers. Report: CC BY 4.0. Upstream patches retain their upstream licenses.