Survival selection — which factors predict time to death?¶

Bayesian variable selection, Part 10 — censored outcomes¶

The last outcome type is time-to-event, and it brings a complication no earlier notebook faced: censoring. When the study ends, many patients are still alive — we know only that their survival time exceeds their follow-up, not what it is. A selection method must use that partial information correctly, or it will be badly biased.

The classic testbed is the Mayo Clinic PBC data (primary biliary cirrhosis): 276 patients, 16 candidate covariates, and only 40% observed deaths (60% censored). The famous Mayo "natural history" model, built by painstaking stepwise selection, kept exactly five: bilirubin, albumin, age, edema, prothrombin time. Can Bayesian variable selection recover them? We fit a Weibull proportional-hazards model with a spike-and-slab prior (Part 1's method, extended to censored data), and cross-check against PyMC and R's BMA::bic.surv (model averaging over Cox models).

The model, and how censoring enters¶

A Weibull proportional-hazards model. With standardized covariates $x$, an event indicator $\delta_i$ ($1$ = death observed, $0$ = censored) and follow-up time $t_i$: $$ h(t\mid x) = \alpha\,t^{\alpha-1}\exp(\beta_0 + x'\beta),\qquad \log L = \sum_i\Big[\delta_i\big(\log\alpha + (\alpha-1)\log t_i + \beta_0 + x_i'\beta\big) - t_i^{\alpha}\exp(\beta_0+x_i'\beta)\Big]. $$ The second term — the cumulative hazard — is subtracted for every subject; only an observed death adds the log-hazard. That is exactly how a censored patient contributes "survived at least this long" ($\log S(t_i)=-t_i^\alpha e^{\eta}$) rather than "died at $t_i$". Spike-and-slab on the coefficients: $$ \beta_j\mid\gamma_j=0\sim\mathcal N(0,\tau^2),\quad \beta_j\mid\gamma_j=1\sim\mathcal N(0,c^2\tau^2),\quad \gamma_j\sim\text{Bernoulli}(w). $$

The sampler. The Weibull likelihood is not conjugate, so $\beta$ is updated by adaptive random-walk Metropolis (with the spike/slab prior variance set by the current $\gamma$), the baseline $(\beta_0,\alpha)$ likewise. But the selection step is unchanged from Part 1: $\gamma_j\mid\beta_j$ is a Bernoulli from the spike/slab density ratio, because that conditional never sees the likelihood. So the whole apparatus is Part-1 SSVS with a Metropolis coefficient move and a survival log-likelihood.

Hyperparameters: spike $\tau=0.05$, slab ratio $c=20$, $w=0.5$; the strongly right-skewed labs (bilirubin, alk.phos, AST, copper, triglycerides, prothrombin) are log-transformed, then all covariates standardized. Engine: survsel.py.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import survsel as S

d = pd.read_csv("pbc_data.csv")
cov = ["age","sex","ascites","hepato","spiders","edema","bili","albumin",
       "copper","alk.phos","ast","trig","platelet","protime","stage","trt"]
Xdf = d[cov].copy()
for v in ["bili","alk.phos","ast","copper","trig","protime"]: Xdf[v] = np.log(Xdf[v])  # skewed labs -> log
X = Xdf.values; t = d["years"].values; delta = d["event"].values                        # delta: 1=death, 0=censored

res = S.weibull_ssvs(t, delta, X, tau=0.05, c=20.0, w=0.5, ndraw=15000, burn=5000, seed=1)
print("Weibull-PH SSVS with right-censoring   (Weibull shape alpha = %.2f, %d deaths / %d)\n" % (res["alpha"], int(delta.sum()), len(delta)))
print("inclusion probabilities:")
order = sorted(zip(cov, res["incl"]), key=lambda x: -x[1])
trt_pi = dict(order)["trt"]
for k, (nm, pi) in enumerate(order[:8]):
    tail = "    (rest < 0.20: trt only %.2f)" % trt_pi if k == 7 else ""
    print("  %-10s %.3f%s" % (nm, pi, tail))

# --- Kaplan-Meier survival by the top selected covariates (do they separate survival?) ---
def _km(tt, ev):
    tt = np.asarray(tt, float); ev = np.asarray(ev, float)
    xs, ys, Sc = [0.0], [1.0], 1.0
    for uu in np.unique(tt):
        d_u = ev[tt == uu].sum()
        if d_u > 0: Sc *= 1.0 - d_u / (tt >= uu).sum()
        xs.append(uu); ys.append(Sc)
    return np.array(xs), np.array(ys)

fig, ax = plt.subplots(1, 2, figsize=(12.4, 4.6))
cols = ["forestgreen", "goldenrod", "firebrick"]
bili0 = d["bili"].values
gb = np.digitize(bili0, np.quantile(bili0, [1/3, 2/3]))          # 0 low, 1 medium, 2 high
blab = ["low bilirubin", "medium", "high bilirubin"]
for g in range(3):
    m = gb == g; xs, ys = _km(t[m], delta[m])
    ax[0].step(xs, ys, where="post", color=cols[g], lw=1.8,
               label="%s (n=%d)" % (blab[g], int(m.sum())))
    c = t[m & (delta == 0)]
    if len(c): ax[0].plot(c, ys[np.searchsorted(xs, c, "right") - 1], "|", color=cols[g], ms=6, mew=1)
ax[0].set_title("Kaplan-Meier by bilirubin (the top selected factor)")

ed = d["edema"].values
elab = ["no edema", "untreated/resolved", "edema despite therapy"]
for g, lev in enumerate([0.0, 0.5, 1.0]):
    m = ed == lev; xs, ys = _km(t[m], delta[m])
    ax[1].step(xs, ys, where="post", color=cols[g], lw=1.8,
               label="%s (n=%d)" % (elab[g], int(m.sum())))
    c = t[m & (delta == 0)]
    if len(c): ax[1].plot(c, ys[np.searchsorted(xs, c, "right") - 1], "|", color=cols[g], ms=6, mew=1)
ax[1].set_title("Kaplan-Meier by edema (another selected factor)")

for a in ax:
    a.set_xlabel("years"); a.set_ylabel("survival probability")
    a.set_ylim(0, 1.0); a.grid(alpha=.25); a.legend()
plt.tight_layout(); plt.show()
Weibull-PH SSVS with right-censoring   (Weibull shape alpha = 1.58, 111 deaths / 276)

inclusion probabilities:
  bili       1.000
  age        0.868
  edema      0.811
  stage      0.644
  copper     0.628
  albumin    0.544
  protime    0.522
  ast        0.284    (rest < 0.20: trt only 0.10)
No description has been provided for this image

Reading the selection¶

  • The Mayo model, recovered. Bilirubin (inclusion $1.00$), age, edema, albumin and prothrombin time — the five factors of the hand-built Mayo natural-history model — are all selected, alongside histologic stage and urinary copper, both established PBC markers. The search found the accepted prognostic set from a Bayesian posterior, censoring and all.
  • The Kaplan-Meier curves confirm it (figure). Split by bilirubin tertile or by edema, the observed survival curves separate dramatically — high-bilirubin patients die far faster. The selection is picking factors with a real, visible effect on survival, not statistical artefacts. (Tick marks are censored patients.)
  • A famous null result. Treatment (trt, D-penicillamine) has inclusion $\approx0.10$ — not selected. That matches the trial's conclusion: the drug did not improve survival. Selection correctly declines to include an ineffective covariate.
In [2]:
import pymc as pm, pytensor.tensor as pt                            # cross-check: same censored Weibull model in a PPL
Xz = (X - X.mean(0)) / X.std(0); logt = np.log(t); p = len(cov)
with pm.Model() as mod:
    tau, c = 0.05, 20.0
    g = pm.Bernoulli("g", 0.5, shape=p); b = pm.Normal("b", 0, 1, shape=p)
    beta = b * pm.math.switch(g, c*tau, tau); b0 = pm.Normal("b0", 0, 10); la = pm.Normal("la", 0, 1); al = pm.math.exp(la)
    eta = b0 + pm.math.dot(Xz, beta)
    pm.Potential("lik", pt.sum(delta*(la + (al-1)*logt + eta) - pm.math.exp(al*logt + eta)))   # censored Weibull log-lik
    idata = pm.sample(1500, tune=1500, chains=2, step=[pm.BinaryGibbsMetropolis([g])], target_accept=0.9, random_seed=1, progressbar=False)
pm_incl = idata.posterior["g"].mean(("chain","draw")).values
print("PyMC Weibull SSVS inclusion (top): ", "  ".join("%s %.2f" % (n, v) for n, v in sorted(zip(cov, pm_incl), key=lambda x: -x[1])[:6]))
print("-> all three engines (+ R Cox BMA) select the same markers; treatment (trt) is NOT selected.")

# --- inclusion probabilities across all three engines ---
# R bic.surv (Cox BMA) posterior inclusion probs (probne0/100, from surv_R.ipynb), in `cov` order:
r_incl = np.array([0.98, 0.04, 0.04, 0.03, 0.04, 0.86, 1.00, 0.81,
                   0.83, 0.03, 0.14, 0.03, 0.03, 0.41, 0.61, 0.03])
o = np.argsort(res["incl"])                                       # ascending -> strongest markers on top
labs = [cov[i] for i in o]; yy = np.arange(len(cov)); hh = 0.27
fig, ax = plt.subplots(figsize=(8.8, 6.2))
ax.barh(yy + hh, r_incl[o],       height=hh, color="tab:blue",   label="R bic.surv (Cox BMA)")
ax.barh(yy,      res["incl"][o],  height=hh, color="tab:orange", label="from-scratch Weibull-PH SSVS")
ax.barh(yy - hh, pm_incl[o],      height=hh, color="tab:green",  label="PyMC Weibull SSVS")
ax.axvline(0.5, ls="--", color="grey", lw=1)
ax.set_yticks(yy); ax.set_yticklabels(labs); ax.set_xlim(0, 1)
ax.set_xlabel("posterior inclusion probability")
ax.set_title("PBC survival: which covariates predict time to death? (three engines, censored data)")
ax.legend(loc="lower right"); ax.grid(axis="x", alpha=.25)
plt.tight_layout(); plt.show()
Multiprocess sampling (2 chains in 2 jobs)
CompoundStep
>BinaryGibbsMetropolis: [g]
>NUTS: [b, b0, la]
Sampling 2 chains for 1_500 tune and 1_500 draw iterations (3_000 + 3_000 draws total) took 9 seconds.
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\arviz_stats\base\diagnostics.py:90: RuntimeWarning: invalid value encountered in scalar divide
  (between_chain_variance / within_chain_variance + num_samples - 1) / (num_samples)
There were 10 divergences after tuning. Increase `target_accept` or reparameterize.
We recommend running at least 4 chains for robust computation of convergence diagnostics
The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details
The effective sample size per chain is smaller than 100 for some parameters.  A higher number is needed for reliable rhat and ess computation. See https://arxiv.org/abs/1903.08008 for details
PyMC Weibull SSVS inclusion (top):  bili 1.00  age 0.88  edema 0.86  stage 0.62  copper 0.61  albumin 0.56
-> all three engines (+ R Cox BMA) select the same markers; treatment (trt) is NOT selected.
No description has been provided for this image

Results — selection with censored data¶

  • Three engines, one prognostic set. The from-scratch Weibull-PH SSVS, PyMC's version, and R's bic.surv (which averages over Cox models) all select the same handful — bilirubin, age, edema, albumin, copper, stage — with prothrombin time on the boundary. That a fully parametric Weibull and a semiparametric Cox agree on which covariates matter confirms the selection is a property of the proportional-hazards structure, not of the baseline-hazard form.
  • Censoring, handled cleanly. With 60% of survival times censored, the likelihood's cumulative-hazard term does the work: a censored patient contributes the probability of surviving past their follow-up, and the selection is unbiased for it. Getting this wrong — e.g. treating censored times as deaths — would corrupt every inclusion probability.
  • The arc's outcome types, complete. Continuous (Parts 1–2, 8), counts and tables (4), multivariate time series (3), binary (9), and now time-to-event — the same spike-and-slab idea travels to each by swapping in the appropriate likelihood, with the inclusion step untouched.

References¶

  • George, E. I. & McCulloch, R. E. (1993). Variable selection via Gibbs sampling. JASA 88, 881–889.
  • Volinsky, C. T., Madigan, D., Raftery, A. E. & Kronmal, R. A. (1997). Bayesian model averaging in proportional hazard models. Applied Statistics 46, 433–448.
  • Dickson, E. R. et al. (1989). Prognosis in primary biliary cirrhosis: model for decision making. Hepatology 10, 1–7.
  • Therneau, T. & Grambsch, P. (2000). Modeling Survival Data. Springer (the pbc dataset).

Data: pbc_data.csv — Mayo PBC trial: 276 patients, 16 covariates, time to death with right-censoring (survival::pbc). Next: surv_R.ipynb — the R cross-check (BMA::bic.surv, Bayesian averaging over Cox models).