Bayesian Weibull Proportional-Hazards Model

Python · R  ·  Download sampler module

Model

Time-to-event data bring a genuinely new ingredient over every earlier example — censoring. A subject still event-free at the end of follow-up contributes only the survival probability S(t)S(t), not a density. With event indicator δi\delta_i (1 = event observed, 0 = censored), the likelihood splits in two: events contribute the hazard × survival, censored cases only the survival. The Weibull proportional-hazards form gives h(tx)=λiktk1h(t\mid x) = \lambda_i k\, t^{k-1} with λi=exiβ\lambda_i = e^{x_i'\beta} — so eβje^{\beta_j} is the hazard ratio for covariate jj, and the shape kk controls whether hazard rises (k>1k>1), falls (k<1k<1), or is constant (k=1k=1, the exponential model).

L=ih(ti)δiS(ti),h(tx)=λiktk1,λi=exiβ,S(t)=eλitkL = \prod_i h(t_i)^{\delta_i}\, S(t_i), \qquad h(t\mid x) = \lambda_i k\, t^{k-1}, \quad \lambda_i = e^{x_i'\beta}, \quad S(t) = e^{-\lambda_i t^k}
logL=iδi[xiβ+logk+(k1)logti]iexiβtik\log L = \sum_i \delta_i\big[x_i'\beta + \log k + (k-1)\log t_i\big] - \sum_i e^{x_i'\beta} t_i^{k}

Survival is a count model underneath

A nice structural fact: the Weibull log-likelihood is a Poisson GLM kernel in disguise (δ\delta as 0/1 "counts", offset logtk\log t^k) — the piecewise-exponential = Poisson trick behind the BUGS Leuk example, linking survival analysis directly back to the count-model arc. Everything else is familiar GLM machinery: MLE by direct maximisation, a Bayesian RW-Metropolis on (β,logk)(\beta, \log k) with a full-covariance proposal from the MLE Hessian, and a Kaplan–Meier curve for nonparametric comparison.

ContributionSubject typeLikelihood term
δi=1\delta_i = 1event observedh(ti)S(ti)h(t_i)\,S(t_i) — hazard × survival (a density)
δi=0\delta_i = 0right-censoredS(ti)S(t_i) — survival only (event time exceeds tit_i)

Notebooks

Three applications share the same survival_mcmc.py machinery, each surfacing a different facet of the Weibull. Leuk — treatment effect (BUGS Leuk). 42 leukemia patients randomised to 6-MP (mercaptopurine) vs. placebo, time to relapse in weeks. The placebo arm relapsed fast and fully (all 21 events, median 8 weeks); the 6-MP arm did far better, with 12 of 21 still in remission at study end (censored). The control relapse hazard is 56×\approx 5\text{–}6\times higher than 6-MP's (6-MP HR 0.18\approx 0.18; P(control HR>1)=1.00P(\text{control HR}>1)=1.00), with a mildly increasing baseline (k1.35k\approx 1.35). Ignoring the censored patients would understate the benefit — the censored likelihood credits them with surviving at least that long. Mice — a multi-level factor & wear-out shape (BUGS Mice). 80 mice in 4 groups, survival in weeks. The covariate is a 4-level group factor, and the shape comes out strongly increasing (k3.2k\approx 3.2) — an ageing/wear-out hazard that decisively rules out the exponential, the qualitative contrast with Leuk's near-flat kk. Group 2 survives longest (median 35\approx 35 wk, HR 0.3\approx 0.3), group 4 shortest. Reliability — engineering life metrics (Meeker & Escobar shock absorber). A single sample (no covariates) of 38 vehicles' distance-to-failure, with 23 of 38 suspended (61% censored). The shape k3.3k\approx 3.3 signals a clear wear-out failure mode, and the engineering quantities read straight off the posterior — characteristic life η25,500\eta\approx 25{,}500 km, B1012,600B_{10}\approx 12{,}600 km, MTTF 22,700\approx 22{,}700 km — with a Weibull probability plot confirming adequacy. Every application is cross-checked three ways — from-scratch (MLE + Bayes), PyMC (censored Weibull via pm.Potential, NUTS), and R (survival::survreg Weibull AFT) — with R's semiparametric Cox coxph as an extra benchmark on Leuk that recovers the same HR without assuming a Weibull baseline.

Downloads

References

Sampler Module — Source Code

"""
Parametric survival regression (Weibull / exponential proportional hazards) with RIGHT-CENSORING --
from-scratch MLE + Bayesian RW-Metropolis. The BUGS Vol I "Leuk" example (Gehan leukemia, 6-MP vs
placebo) and the PyMC Bayesian-survival case study.

The new ingredient vs every earlier project is CENSORING: a subject still in remission at the end of
follow-up contributes only the survival probability S(t), not a density. With event indicator delta_i
(1 = relapse observed, 0 = censored):
    L = prod_i  h(t_i)^{delta_i} * S(t_i)
i.e. events contribute the hazard x survival, censored cases only the survival.

Weibull proportional-hazards parameterisation
---------------------------------------------
  h(t | x) = lambda_i * k * t^{k-1},   lambda_i = exp(x_i' beta),   S(t) = exp(-lambda_i t^k)
  log L = sum_i delta_i [ x_i'beta + log k + (k-1) log t_i ]  -  sum_i exp(x_i'beta) t_i^k
k = Weibull shape: k>1 increasing hazard, k<1 decreasing, k=1 = EXPONENTIAL (constant hazard).
exp(beta_j) is the HAZARD RATIO for covariate j. Note the log-lik is a Poisson GLM kernel in disguise
(delta as 0/1 "counts", offset log t^k) -- the piecewise-exponential/Poisson trick behind BUGS Leuk.

Priors (Bayes): beta ~ N(0, prior_sd^2),  log k ~ N(0, 1).
"""
import numpy as np
from scipy.optimize import minimize


def simulate_weibull(n=600, beta=(-1.0, -1.3), k=1.4, cens_scale=3.0, seed=0):
    """X = [1, treat]; right-censor by an independent exponential follow-up time."""
    rng = np.random.default_rng(seed)
    beta = np.asarray(beta, float)
    treat = rng.integers(0, 2, n).astype(float)
    X = np.column_stack([np.ones(n), treat])
    lam = np.exp(X @ beta)
    T = (-np.log(rng.random(n)) / lam) ** (1.0 / k)          # Weibull event times
    C = rng.exponential(cens_scale, n)                        # random right-censoring
    t = np.minimum(T, C); delta = (T <= C).astype(float)
    return t, X, delta


def _negloglik(params, X, t, delta, fix_k):
    k = 1.0 if fix_k else np.exp(params[-1])
    beta = params if fix_k else params[:-1]
    xb = X @ beta
    ll = np.sum(delta * (xb + np.log(k) + (k - 1) * np.log(t))) - np.sum(np.exp(xb) * t ** k)
    return -ll


def _num_hess(f, x, eps=1e-4):
    n = len(x); H = np.zeros((n, n)); fx = f(x)
    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 weibull_mle(X, t, delta, fix_k=False):
    k0 = X.shape[1] + (0 if fix_k else 1)
    x0 = np.zeros(k0)
    res = minimize(_negloglik, x0, args=(X, t, delta, fix_k), method='Nelder-Mead',
                   options=dict(xatol=1e-8, fatol=1e-8, maxiter=20000))
    H = _num_hess(lambda p: _negloglik(p, X, t, delta, fix_k), res.x)
    se = np.sqrt(np.diag(np.linalg.inv(H)))
    return res.x, se, -res.fun


def weibull_rwm(X, t, delta, R=12000, burn=3000, prior_sd=10.0, fix_k=False, seed=0, s=1.0):
    rng = np.random.default_rng(seed)
    mle, _, _ = weibull_mle(X, t, delta, fix_k)
    H = _num_hess(lambda p: _negloglik(p, X, t, delta, fix_k), mle)
    L = np.linalg.cholesky(np.linalg.inv(H)) * s
    p = mle.copy(); d = len(p)

    def logpost(par):
        beta = par if fix_k else par[:-1]
        lp = -0.5 * np.sum(beta ** 2) / prior_sd ** 2
        if not fix_k:
            lp += -0.5 * par[-1] ** 2          # log k ~ N(0,1)
        return -_negloglik(par, X, t, delta, fix_k) + lp

    keep = R - burn; P = np.zeros((keep, d)); acc = 0; cur = logpost(p)
    for it in range(R):
        prop = p + L @ rng.standard_normal(d)
        lp = logpost(prop)
        if np.log(rng.random()) < lp - cur:
            p, cur = prop, lp; acc += 1
        if it >= burn:
            P[it - burn] = p
    return dict(draws=P, accept=acc / R, fix_k=fix_k)


def km_estimator(t, delta):
    """Kaplan-Meier product-limit estimate. Returns (times, survival)."""
    order = np.argsort(t); t, delta = t[order], delta[order]
    uniq = np.unique(t[delta == 1])
    n = len(t); S = 1.0; ts = [0.0]; Ss = [1.0]
    for ti in uniq:
        at_risk = np.sum(t >= ti)
        d = np.sum((t == ti) & (delta == 1))
        S *= (1 - d / at_risk); ts.append(ti); Ss.append(S)
    return np.array(ts), np.array(Ss)


def summary(draws, names):
    return [(nm, draws[:, j].mean(), draws[:, j].std(), *np.percentile(draws[:, j], [2.5, 97.5]))
            for j, nm in enumerate(names)]