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 , not a density. With event indicator (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 with — so is the hazard ratio for covariate , and the shape controls whether hazard rises (), falls (), or is constant (, the exponential model).
- — event indicator (1 = event observed, 0 = right-censored)
- — hazard ratio for covariate
- — Weibull shape: rising, falling, exponential (constant hazard)
Survival is a count model underneath
A nice structural fact: the Weibull log-likelihood is a Poisson GLM kernel in disguise ( as 0/1 "counts", offset ) — 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 with a full-covariance proposal from the MLE Hessian, and a Kaplan–Meier curve for nonparametric comparison.
| Contribution | Subject type | Likelihood term |
|---|---|---|
| event observed | — hazard × survival (a density) | |
| right-censored | — survival only (event time exceeds ) |
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 higher than 6-MP's (6-MP HR ; ), with a mildly increasing baseline (). 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 () — an ageing/wear-out hazard that decisively rules out the exponential, the qualitative contrast with Leuk's near-flat . Group 2 survives longest (median wk, HR ), 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 signals a clear wear-out failure mode, and the engineering quantities read straight off the posterior — characteristic life km, km, MTTF 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
survival_mcmc.py From-scratch Weibull PH — MLE + RW-Metropolis with censoring, Kaplan–Meier (NumPy / scipy) leuk.csv Gehan leukemia trial (BUGS Leuk) — 42 patients, 6-MP vs placebo, weeks to relapse mice.csv BUGS Mice experiment — 80 mice in 4 groups, survival in weeks shock_absorber.csv Meeker & Escobar shock-absorber life data — 38 vehicles, km to failure (23 suspended) References
- Weibull, W. (1951). A statistical distribution function of wide applicability. Journal of Applied Mechanics 18(3), 293–297. — the Weibull distribution and its shape parameter
- Cox, D. R. (1972). Regression models and life-tables. Journal of the Royal Statistical Society: Series B 34(2), 187–220. — proportional hazards and the semiparametric Cox benchmark
- Kaplan, E. L. & Meier, P. (1958). Nonparametric estimation from incomplete observations. Journal of the American Statistical Association 53(282), 457–481. — the Kaplan–Meier survival curve used for comparison
- Freireich, E. J. et al. (1963). The effect of 6-mercaptopurine on the duration of steroid-induced remissions in acute leukemia. Blood 21(6), 699–716. — the leukemia remission trial (the BUGS Leuk data)
- Meeker, W. Q. & Escobar, L. A. (1998). Statistical Methods for Reliability Data. Wiley. — the shock-absorber life data and the η / B₁₀ / MTTF reliability metrics
- Kalbfleisch, J. D. & Prentice, R. L. (2002). The Statistical Analysis of Failure Time Data (2nd ed.). Wiley. — parametric survival, censoring, and PH-vs-AFT parameterisations
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)]