Bayesian Cox via Poisson

Python · R  ·  Download sampler module

Model

The Cox proportional-hazards model — the semiparametric workhorse of survival analysis — can be fit as an ordinary Poisson GLM on expanded person-time data. This is the literal form used by BUGS Vol I Leuk and PyMC's "Bayesian survival analysis" case study, and it closes the loop on the recurring theme of this site: survival is a count model. The earlier Weibull example noted its likelihood was a Poisson kernel; here that observation becomes the estimation method itself, with no parametric baseline assumed.

yigPoisson(μig),logμig=log(exposureig)offset+λg+xiβy_{ig} \sim \text{Poisson}(\mu_{ig}), \qquad \log\mu_{ig} = \underbrace{\log(\text{exposure}_{ig})}_{\text{offset}} + \lambda_g + x_i'\beta

The piecewise-exponential = Poisson identity

Split the time axis at the distinct event times into intervals gg with a piecewise-constant baseline hazard λg\lambda_g, and expand each subject into one row per interval they are at risk in. Then yigy_{ig} (1 if subject ii failed in interval gg) is Poisson with a log-exposure offset — exactly the count regression model with interval intercepts. As the cuts approach every event time, β^\hat\beta converges to the Cox partial-likelihood estimate (Holford 1980; Laird & Olivier 1981): λg\lambda_g is a nonparametric step baseline (no Weibull shape imposed), and eβe^{\beta} is the hazard ratio — the same quantity as coxph, obtained from a count model.

Why the bridge matters

Why it matters: anything available for Poisson GLMs immediately enriches the survival model — splines on λg\lambda_g, extra covariates, or random effects. Adding a patient random intercept to this Poisson turns it into the frailty model as a hierarchical Poisson — the same modular pieces, recombined.

Notebooks

Two datasets, identical machinery (the same coxpois.py, only the covariate changes). Leuk (Gehan leukemia, the Weibull project's data): the Poisson-on-person-time fit recovers control-vs-6-MP HR 5\approx 5 — matching coxph (4.8) and the Weibull fit (5.6), but with the baseline estimated nonparametrically (it tracks the Weibull baseline closely, which is why the parametric choice was reasonable — without having to assume it). Mastectomy (PyMC's canonical example; 44 breast-cancer patients): metastasis raises the death hazard ~2× (MLE/coxph HR 2.3\approx 2.3, the Bayesian posterior a milder HR 1.92.0\approx 1.9\text{–}2.0 with P(HR>1)0.92P(\text{HR}>1)\approx 0.92) — its credible interval still includes 1, an uncertain effect with only 26 deaths. Each is cross-checked three ways — from-scratch Poisson (MLE + RW-Metropolis), PyMC pm.Poisson on the same expansion, and R survSplit + glm(poisson) — with coxph as the partial-likelihood reference the piecewise-exponential Poisson approximates.

Downloads

References

Sampler Module — Source Code

"""
Cox proportional hazards via the PIECEWISE-EXPONENTIAL = POISSON identity -- the count<->survival bridge.
BUGS Vol I "Leuk" is written in exactly this form; the PyMC "Bayesian survival analysis" (mastectomy)
case study uses it too.

Idea
----
Split the time axis at the distinct event times into intervals g with a piecewise-CONSTANT baseline
hazard lambda_g. Expand each subject into one "person-time" row per interval they are at risk in. Then
  y_ig ~ Poisson(mu_ig),   log mu_ig = log(exposure_ig) + lambda_g + x_i' beta
where y_ig = 1 if subject i had the event in interval g (else 0), exposure_ig = time at risk in interval
g (the OFFSET), and lambda_g = log baseline hazard. This is just a Poisson GLM with an offset and
interval intercepts -> fit it with count_reg_mcmc.poisson_*. As the cuts -> every event time, beta is the
Cox partial-likelihood estimate (Holford 1980; Laird & Olivier 1981). No parametric baseline assumed.

survsplit() does the person-time expansion; build_design() assembles [interval dummies | covariates].
The Poisson fitters below are SELF-CONTAINED (same model as count_reg_mcmc's Poisson, re-implemented here
with a log-exposure OFFSET so this project doesn't modify the older count-regression module).
"""
import numpy as np
from scipy.special import gammaln


def survsplit(time, status, cut):
    """Expand to person-time rows. `cut` = interior cutpoints (sorted). Intervals: (0,c1],...,(c_{K-1},inf).
    Returns (subj, interval, exposure, event) arrays, one entry per (subject, at-risk interval)."""
    time = np.asarray(time, float); status = np.asarray(status, float)
    edges = np.concatenate([[0.0], np.asarray(cut, float)]); G = len(edges)   # G intervals (last open)
    subj = []; itv = []; expo = []; ev = []
    for i, (ti, di) in enumerate(zip(time, status)):
        for g in range(G):
            lo = edges[g]; hi = edges[g + 1] if g + 1 < G else np.inf
            if ti <= lo:
                break
            exits = ti <= hi
            subj.append(i); itv.append(g); expo.append(min(ti, hi) - lo); ev.append(di if exits else 0.0)
            if exits:
                break
    return np.array(subj, int), np.array(itv, int), np.array(expo, float), np.array(ev, float)


def cutpoints(time, status):
    """Distinct event times, minus the last (interior breakpoints) so the final interval holds the last
    event plus any later censoring -> every interval contains >=1 event."""
    ev_times = np.unique(np.asarray(time)[np.asarray(status) == 1])
    return ev_times[:-1]


def build_design(itv, G, covars):
    """[interval dummies (G one-hot cols) | covariate columns]. No global intercept."""
    D = np.zeros((len(itv), G))
    D[np.arange(len(itv)), itv] = 1.0
    return np.column_stack([D] + [np.asarray(c, float) for c in covars])


# ── self-contained Poisson regression with a log-exposure offset (the piecewise-exp likelihood) ──
def pois_mle(X, y, offset, tol=1e-10, maxit=200):
    """Newton/IRLS Poisson MLE with offset. log mu = X beta + offset. Returns (beta, se)."""
    k = X.shape[1]; beta = np.zeros(k)
    for _ in range(maxit):
        mu = np.exp(X @ beta + offset)
        I = (X * mu[:, None]).T @ X
        step = np.linalg.solve(I, X.T @ (y - mu)); beta += step
        if np.max(np.abs(step)) < tol:
            break
    cov = np.linalg.inv((X * np.exp(X @ beta + offset)[:, None]).T @ X)
    return beta, np.sqrt(np.diag(cov))


def pois_rwm(y, X, offset, R=10000, burn=2000, prior_sd=10.0, seed=0, s=None):
    """RW-Metropolis for the offset-Poisson; proposal scaled by the Fisher info at the MLE."""
    rng = np.random.default_rng(seed); n, k = X.shape
    b0, _ = pois_mle(X, y, offset)
    I = (X * np.exp(X @ b0 + offset)[:, None]).T @ X
    L = np.linalg.cholesky(np.linalg.inv(I + np.eye(k) / prior_sd ** 2))
    s = (2.38 / np.sqrt(k)) if s is None else s

    def lp(b):
        eta = X @ b + offset
        return np.sum(y * eta - np.exp(eta)) - 0.5 * np.sum(b ** 2) / prior_sd ** 2

    beta = b0.copy(); cur = lp(beta); keep = R - burn; B = np.zeros((keep, k)); acc = 0
    for r in range(R):
        prop = beta + s * (L @ rng.standard_normal(k))
        pr = lp(prop)
        if np.log(rng.random()) < pr - cur:
            beta, cur = prop, pr; acc += 1
        if r >= burn:
            B[r - burn] = beta
    return dict(beta=B, accept=acc / R)