Bayesian Interval-Censored Survival
Python · R · Download sampler module
Model
The sequel to the Weibull survival example, which handled right-censoring only. In many studies subjects are checked periodically, so an event is known only to have happened between two visits — interval censoring. With and , each observation with event time in contributes — a single likelihood that covers every censoring type at once. The midpoint-imputation shortcut (inventing exact times at interval centres) biases estimates when intervals are wide; the interval likelihood uses honestly.
One likelihood for every censoring type
| Type | Bounds | Contribution |
|---|---|---|
| interval | ||
| left | — event before first visit | |
| right | — no event by last visit | |
| exact | density |
Censoring is one idea
This completes the censoring tour. collapses to right-censoring (), left-censoring (), interval, and — with the density — exact. Together with the right-censored survival and the Tobit (left/interval Gaussian), the message is one idea: censoring is a probability mass over the region the value is known to lie in. Estimation is the familiar pair — MLE by direct maximisation of (with a numerically stable log-difference), and a Bayesian RW-Metropolis on with a proposal from the MLE Hessian.
Notebooks
Applied to the classic mouse lung-tumour data (Hoel & Walburg 1972; from R's icenReg): 144 male mice in two housing environments — conventional (96) vs. germ-free (48) — examined for lung tumours only at death, so each onset time is left-censored (tumour already present — 62 mice) or right-censored (no tumour by death — 82), with no exactly-observed times. No finite-width intervals happen to occur in this sample, but the same likelihood covers the left/right mix in one stroke — including the left-censored cases a plain right-censored Weibull cannot use. Germ-free mice have a higher tumour hazard — the from-scratch fit gives HR with , against from R's survreg and icenReg::ic\_par; PyMC's runs higher because it reports the posterior mean of rather than at the posterior mean, the same convention gap set out on the Weibull page, Weibull shape by MLE (the Bayesian fits give , still increasing). The notebook is careful about the "germ-free puzzle": sterility doesn't cause cancer — the standard explanation is competing risks / longevity (conventional mice die earlier of infection, before a slow tumour appears), and the model estimates only the onset hazard, so it can't by itself separate "more tumour biology" from "less competing death." Cross-checked four ways — from-scratch (MLE + Bayes), PyMC (interval likelihood via pm.Potential), and R survreg(type='interval2') + the purpose-built icenReg::ic_par.
Downloads
References
- Hoel, D. G. & Walburg, H. E. (1972). Statistical analysis of survival experiments. Journal of the National Cancer Institute 49(2), 361–372. — the mouse lung-tumour data analyzed here
- Turnbull, B. W. (1976). The empirical distribution function with arbitrarily grouped, censored and truncated data. Journal of the Royal Statistical Society: Series B 38(3), 290–295. — the nonparametric estimator for interval-censored data
- Finkelstein, D. M. (1986). A proportional hazards model for interval-censored failure time data. Biometrics 42(4), 845–854. — proportional hazards regression under interval censoring
- Sun, J. (2006). The Statistical Analysis of Interval-Censored Failure Time Data. Springer. — the standard monograph on interval-censored survival
- Anderson-Bergman, C. (2017). icenReg: regression models for interval censored data in R. Journal of Statistical Software 81(12), 1–23. — the
icenReg::ic_parimplementation used in the R cross-check
Sampler Module — Source Code
"""
Interval-censored survival -- Weibull proportional hazards from scratch (MLE + Bayes RW-Metropolis).
The natural sequel to survival_mcmc.py (right-censoring only): here the event time is known only to lie
in an interval (l, u]. One likelihood covers every censoring type:
contribution_i = S(l_i) - S(u_i), S(t) = exp(-lambda_i t^k), lambda_i = exp(x_i' beta)
interval (l<u<inf): S(l)-S(u) (event between two visits)
left (l=0) : S(0)-S(u) = 1 - S(u) = F(u) (event before first visit)
right (u=inf) : S(l)-0 = S(l) (no event by last visit)
exact (l=u) : handled with the density f(l) = lambda k l^{k-1} S(l)
(Kept as a NEW module rather than editing survival_mcmc.py, which does right-censoring only.)
"""
import numpy as np
from scipy.optimize import minimize
def simulate_icweib(n=1500, beta=(-7.0, 0.8), k=2.0, n_visits=6, t_max=None, seed=0):
"""Weibull event times, then INTERVAL-censor by a grid of inspection visits (current-status-like)."""
rng = np.random.default_rng(seed); beta = np.asarray(beta, float)
grp = rng.integers(0, 2, n).astype(float); X = np.column_stack([np.ones(n), grp])
lam = np.exp(X @ beta)
T = (-np.log(rng.random(n)) / lam) ** (1.0 / k)
t_max = (np.quantile(T, 0.9)) if t_max is None else t_max
visits = np.sort(rng.uniform(0, t_max, size=(n, n_visits)), axis=1)
l = np.zeros(n); u = np.full(n, np.inf)
for i in range(n):
below = visits[i][visits[i] < T[i]]; above = visits[i][visits[i] >= T[i]]
l[i] = below[-1] if below.size else 0.0
u[i] = above[0] if above.size else np.inf
exact = np.zeros(n, bool)
return l, u, X, exact, T
def _ic_negloglik(par, X, l, u, exact):
k = np.exp(par[-1]); beta = par[:-1]; lam = np.exp(X @ beta)
ll = 0.0
if exact.any():
e = exact
ll += np.sum(np.log(lam[e] * k * l[e] ** (k - 1)) - lam[e] * l[e] ** k)
c = ~exact
if c.any():
a = -lam[c] * l[c] ** k # log S(l)
bu = np.where(np.isinf(u[c]), -np.inf, -lam[c] * np.where(np.isinf(u[c]), 0.0, u[c]) ** k) # log S(u)
ll += np.sum(a + np.log(-np.expm1(bu - a))) # log(S(l) - S(u)), stable
return -ll
def _num_hess(f, x, eps=1e-4):
n = len(x); H = np.zeros((n, n));
for i in range(n):
for j in range(i, n):
xpp = x.copy(); xpp[i] += eps; xpp[j] += eps
xpm = x.copy(); xpm[i] += eps; xpm[j] -= eps
xmp = x.copy(); xmp[i] -= eps; xmp[j] += eps
xmm = x.copy(); xmm[i] -= eps; xmm[j] -= eps
H[i, j] = H[j, i] = (f(xpp) - f(xpm) - f(xmp) + f(xmm)) / (4 * eps ** 2)
return H
def icweib_mle(X, l, u, exact):
k0 = X.shape[1]
x0 = np.concatenate([np.zeros(k0), [0.0]]); x0[0] = -np.log(np.mean(l[l > 0]) + 1.0)
res = minimize(_ic_negloglik, x0, args=(X, l, u, exact), method='Nelder-Mead',
options=dict(maxiter=40000, xatol=1e-8, fatol=1e-8))
H = _num_hess(lambda p: _ic_negloglik(p, X, l, u, exact), res.x)
se = np.sqrt(np.diag(np.linalg.inv(H)))
return res.x, se
def icweib_rwm(X, l, u, exact, R=15000, burn=4000, prior_sd=100.0, logk_sd=5.0, seed=0, s=0.6):
rng = np.random.default_rng(seed)
mle, _ = icweib_mle(X, l, u, exact)
H = _num_hess(lambda p: _ic_negloglik(p, X, l, u, exact), mle)
L = np.linalg.cholesky(np.linalg.inv(H)) * s; d = len(mle)
def lp(par):
beta = par[:-1]
return -_ic_negloglik(par, X, l, u, exact) - 0.5 * np.sum(beta ** 2) / prior_sd ** 2 \
- 0.5 * par[-1] ** 2 / logk_sd ** 2
p = mle.copy(); cur = lp(p); keep = R - burn; P = np.zeros((keep, d)); acc = 0
for it in range(R):
prop = p + L @ rng.standard_normal(d)
pr = lp(prop)
if np.log(rng.random()) < pr - cur:
p, cur = prop, pr; acc += 1
if it >= burn:
P[it - burn] = p
return dict(draws=P, accept=acc / R)
def summary(draws, names):
return [(names[j], draws[:, j].mean(), draws[:, j].std(), *np.percentile(draws[:, j], [2.5, 97.5]))
for j in range(draws.shape[1])]