Skip to content

Instantly share code, notes, and snippets.

@jonesor
Last active July 18, 2026 21:24
Show Gist options
  • Select an option

  • Save jonesor/86c3eeea2a176df9c6e8026810649449 to your computer and use it in GitHub Desktop.

Select an option

Save jonesor/86c3eeea2a176df9c6e8026810649449 to your computer and use it in GitHub Desktop.
Don't feed a cumulative hazard to dbinom(): use q = 1 - exp(-H). A short Gompertz illustration of a common survival/life-table bug.

Don't feed a cumulative hazard to dbinom() — use q = 1 − exp(−H)

A short note on a bug that is easy to write, hard to see, and lives in a lot of hand-rolled survival and life-table code.

The setup

Fit a parametric mortality model (Gompertz, Gompertz–Makeham, Siler, …) to a life table by maximum likelihood and you eventually need the probability that an individual alive at the start of an age interval dies during it. If you have Nx individuals at risk and observe Dx deaths, the cohort likelihood is binomial:

dbinom(Dx, Nx, p, log = TRUE)

The third argument p must be a probability in [0, 1]. The natural thing to reach for is the model's hazard, so you compute the cumulative hazard over the interval and pass it in:

dbinom(Dx, Nx, H, log = TRUE)   # <- looks right, is wrong

That is the bug.

Why H is the wrong quantity

H is the cumulative hazard over the interval — the integral of the instantaneous hazard μ(x):

H = ∫ μ(x) dx   over the interval

It is a perfectly good quantity, but it is not a probability: it runs from 0 to ∞. The probability you actually want comes from it through the survival function:

  • probability of surviving the interval: S = exp(−H)
  • probability of dying in the interval: q = 1 − exp(−H)

Because exp(−H) maps [0, ∞) onto (0, 1], q = 1 − exp(−H) is always a legal probability. Raw H is not — not because a number above 1 is meaningless (a cumulative hazard above 1 is fine), but because it is being used as a probability where only [0, 1] is allowed.

Why it slips past you

For small H, 1 − exp(−H) ≈ H, so at young and middle ages the two agree to several decimal places and every test you run looks fine. The disagreement only grows in the tail — which, if you study ageing, is exactly the part you care about, and exactly the part your quick sanity checks tend to miss.

With a Gompertz hazard μ(x) = a·e^(bx), a = 3×10⁻⁵, b = 0.13 per year, the raw cumulative hazard crosses 1 at about age 80 and keeps climbing — H ≈ 3.9 at 90, ≈ 14 at 100. Fed to dbinom as a probability, each of those returns NaN, the log-likelihood becomes NaN, and the optimiser stalls or wanders off. The corrected curve bends over and saturates just below 1, as a death probability must. Even below age 80, where nothing crashes, feeding raw H quietly overstates the death probability and biases the parameter estimates.

The tell: rate vs. probability

The reason this is so easy to write is a life-table likelihood usually has two branches, and a binomial slot and a Poisson slot want genuinely different things:

# Cohort data (binomial): Dx deaths out of Nx alive at the start of the interval
#   WRONG: dbinom(Dx, Nx, H,           log = TRUE)   # H used as a probability -> NaN once H > 1
#   RIGHT: dbinom(Dx, Nx, 1 - exp(-H), log = TRUE)   # q in [0, 1]

# Period / event-count data (Poisson): Dx deaths given exposure
#   dpois(Dx, Lambda, log = TRUE)   # Lambda = expected deaths = ∫ Y(t) μ(t) dt
#   e.g. dpois(Dx, Ex * mx, log = TRUE)   with person-years Ex and rate mx

A binomial needs a bounded probability; a Poisson needs an expected count (a rate integrated over exposure), which is unbounded. Neither slot takes the raw cumulative hazard as-is.

One caution worth stating precisely, because it is itself a common shortcut: if you write the Poisson mean as Nx * H with Nx the number alive at the start of the interval, that is only a low-mortality approximation. The exact expected death count is Nx * (1 − exp(−H)), and — no surprise — it diverges from Nx * H in the same tail, for the same reason. Nx * H is exact only under particular exposure assumptions (e.g. constant number at risk over the interval). Use person-time exposure Ex * mx when you have it.

Takeaway

A cumulative hazard is not a probability, and it is not an expected count either. When you convert a hazard to a probability, the bridge is the survival function: q = 1 − exp(−H) (in code, -expm1(-H) is a touch more accurate for small H). If a hazard is going straight into anything that expects a probability, that's the line to check.

gompertz_death_prob.R in this gist regenerates the figure from scratch in base R.

# Cumulative hazard vs. probability of death: a Gompertz illustration
#
# Why you can't feed a cumulative hazard straight into dbinom().
# A cumulative hazard H runs from 0 to infinity; a probability must be in [0, 1].
# The bridge is the survival function: q = 1 - exp(-H).
# --- Gompertz hazard: mu(x) = a * exp(b * x) ---
a <- 3e-5 # baseline mortality
b <- 0.13 # rate of ageing (per year)
# NOTE: these are overlapping one-year windows purely to draw a smooth curve.
# A real life table would use non-overlapping interval start-ages.
ages <- seq(0, 110, by = 0.5)
# Cumulative hazard over the one-year interval [x, x+1]:
# H(x) = integral_x^{x+1} a e^{bt} dt = (a/b) e^{bx} (e^b - 1)
H <- (a / b) * exp(b * ages) * (exp(b) - 1)
old <- H # the cumulative hazard itself (a fine quantity...)
new <- -expm1(-H) # ...but the death probability is q = 1 - exp(-H);
# -expm1(-H) is more accurate than 1 - exp(-H) for small H
# Age at which the cumulative hazard passes 1
# (the point beyond which using H *as a binomial probability* is illegal)
x_cross <- log(1 / ((a / b) * (exp(b) - 1))) / b
# --- Plot ---
plot(ages, new, type = "l", lwd = 3, col = "#1f4e79",
ylim = c(0, 2.6), xlab = "Age (years)",
ylab = "Death probability (blue) vs. cumulative hazard (red)",
main = "Gompertz mortality: raw H vs. 1 - exp(-H)")
# shade the region where H > 1 (illegal only when H is used as a probability)
rect(0, 1, 110, 2.6, col = rgb(0.84, 0.15, 0.16, 0.06), border = NA)
abline(h = 1, lty = 3, col = "#d62728")
lines(ages, old, lwd = 2.5, lty = 2, col = "#d62728")
lines(ages, new, lwd = 3, col = "#1f4e79") # redraw on top
abline(v = x_cross, lty = 3, col = "grey50")
points(x_cross, 1, pch = 19, col = "#d62728")
text(x_cross, 1.9,
sprintf("age ~ %.0f: H passes 1\n(illegal as a dbinom probability beyond here)", x_cross),
pos = 2, cex = 0.8)
legend("topleft", bty = "n",
legend = c("cumulative hazard H", "death probability q = 1 - exp(-H)"),
col = c("#d62728", "#1f4e79"), lwd = c(2.5, 3), lty = c(2, 1))
# --- A few reference values ---
for (x in c(40, 60, 80, 90, 100)) {
Hx <- (a / b) * exp(b * x) * (exp(b) - 1)
cat(sprintf("age %3d: H = %6.3f q = 1 - exp(-H) = %.3f%s\n",
x, Hx, -expm1(-Hx),
if (Hx > 1) " (H > 1: invalid as a binomial probability)" else ""))
}
# --- Cohort and period likelihoods (the two-branch contrast) ---
#
# Cohort data: Dx deaths among Nx alive at the START of the interval.
# Assuming independent survival:
#
# qx <- -expm1(-H)
# dbinom(Dx, Nx, qx, log = TRUE)
#
# H itself is not a probability and must not be supplied as dbinom(prob = ).
#
# Period / event-count data: Dx deaths given exposure.
#
# Lambda_x <- integral of Y(t) * mu(t) over the interval # expected count
# dpois(Dx, Lambda_x, log = TRUE)
#
# With person-time exposure Ex and an appropriate mortality rate mx:
#
# dpois(Dx, Ex * mx, log = TRUE)
#
# Writing the Poisson mean as Nx * H (Nx = initial cohort size) is EXACT only
# under restrictive exposure assumptions; in general it is a low-mortality
# approximation to the exact expected count Nx * (1 - exp(-H)).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment