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 visitsinterval censoring. With S(t)=eλitkS(t) = e^{-\lambda_i t^k} and λi=exiβ\lambda_i = e^{x_i'\beta}, each observation with event time in (li,ui](l_i, u_i] contributes Pr(li<Tui)=S(li)S(ui)\Pr(l_i < T \le u_i) = S(l_i) - S(u_i) — 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 S(l)S(u)S(l)-S(u) honestly.

Pr(li<Tui)=S(li)S(ui),S(t)=eλitk,λi=exiβ\Pr(l_i < T \le u_i) = S(l_i) - S(u_i), \qquad S(t) = e^{-\lambda_i t^k}, \quad \lambda_i = e^{x_i'\beta}

One likelihood for every censoring type

TypeBoundsContribution
intervall<u<l < u < \inftyS(l)S(u)S(l) - S(u)
leftl=0l = 0S(0)S(u)=F(u)S(0) - S(u) = F(u) — event before first visit
rightu=u = \inftyS(l)0=S(l)S(l) - 0 = S(l) — no event by last visit
exactl=ul = udensity f(l)=λklk1S(l)f(l) = \lambda k\, l^{k-1} S(l)

Censoring is one idea

This completes the censoring tour. S(l)S(u)S(l)-S(u) collapses to right-censoring (u=u=\infty), left-censoring (l=0l=0), 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 ilog[S(li)S(ui)]\sum_i \log[S(l_i)-S(u_i)] (with a numerically stable log-difference), and a Bayesian RW-Metropolis on (β,logk)(\beta, \log k) 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 S(l)S(u)S(l)-S(u) 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 — HR 2.2\approx 2.2 (P(HR>1)0.99P(\text{HR}>1)\approx 0.99; PyMC's posterior-mean HR runs a bit higher at 2.5\approx 2.5 from averaging eβe^\beta over a right-skewed posterior), Weibull shape k2k\approx 2 by MLE (the Bayesian fits give 1.75\approx 1.75, 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

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])]