Estimating epidemiological delay distributions

From R/Stan to Julia

Sam Abbott

London School of Hygiene & Tropical Medicine

14 August 2026

Talk plan

  • What an epidemiological delay is
  • Unfortunately, biases
  • primarycensored in R, then the same likelihood again in Stan
  • fitdistrplus for fitting, brms for partial pooling
  • The same three adjustments in Julia
  • Who actually uses which version

samabbott.co.uk/JuliaCon2026/delays

JuliaCon 2026, Muschel — N3, Friday 14 August 2026, 14:45.

An epidemiological delay

  • The time between two events in one case. Infection to symptom onset is the incubation period
  • We want the whole distribution over days across a population, not a mean
  • Nowcasts, forecasts and transmission models take one as an input and do not re-estimate it

Diagram for this talk, scripts/delays-natural-history.py

A time axis with four marked events, infection, symptom onset, hospitalisation and death. Three double-headed arrows above the axis span pairs of them: the incubation period from infection to onset, onset to hospitalisation, and onset to death. The last two share a start, so the intervals overlap.

Unfortunately, biases

1. Primary interval censoring

  • The primary event falls somewhere in a window \([0, w_P]\)
  • It need not be uniform, but a uniform primary is fair when the window is short
  • Mix the delay over where in the window the event sat, a convolution on the CDF
  • A uniform primary has closed forms; we are working on more analytical solutions

A timeline. The primary event sits at an unknown point tau inside a blue window of width w_P, the secondary event is a single red line, and the delay is f(t - tau) averaged over tau.

\[ F_{\mathrm{PEC}}(t) = \int_{0}^{w_P} g(\tau)\, F(t - \tau)\, d\tau \]

Diagram from How to serial interval?, labelled there for a serial interval · maths after primarycensored

Note

Gamma, lognormal and Weibull against a uniform primary have closed forms.

2. Secondary interval censoring

  • The secondary event is recorded to the day as well
  • The probability of a daily bin is a difference of the primary-censored CDF
  • It doubles as the probability mass function for daily discrete-time models

The primary-censored delay density, with the daily bin from n to n + w_S shaded red and labelled as the difference of the CDF at its two edges.

\[ \Pr\!\big(T \in [n, n + w_S]\big) = F_{\mathrm{PEC}}(n + w_S) - F_{\mathrm{PEC}}(n) \]

Note

In the past this was often written as a double integral, which is typically more complicated to evaluate.

3. Right truncation

  • A pair enters the data only if the secondary event happened before the cutoff \(C\)
  • Condition on being observed, so divide by the CDF at the horizon
  • Short delays are over-represented early in an epidemic

Five delays from one primary event. The three that end before the cutoff C are drawn in red and observed, the two that end after it are dashed and never seen, and the likelihood is divided by the CDF at C minus P.

\[ L^{\mathrm{RT}} = \frac{F_{\mathrm{PEC}}(n + w_S) - F_{\mathrm{PEC}}(n)} {F_{\mathrm{PEC}}(C - P)} \]

A mean of 3.9 days instead of 5.9

  • At a cutoff seven days after the primary events, and 5.3 days at fourteen

LogNormal(1.6, 0.6) with daily windows, computed for this talk with CensoredDistributions.jl v0.2.22, scripts/delays-right-truncation.jl

Two panels. On the left, the delay distribution seen by day 7 and by day 14 sits well to the left of the true distribution. On the right, the mean estimated delay rises from under 2 days towards the true mean of 5.9 days as the cutoff moves away from now, passing 3.9 days at a cutoff of 7 and 5.3 days at a cutoff of 14.

We can solve this

The integral solves numerically for any pair of delay and primary distribution, and in closed form for a few of them.

primarycensored, an R package

# Weibull delay, uniform primary, truncated to [1, 10]
dprimarycensored(1:9, pweibull, L = 1, D = 10,
                 shape = 1.5, scale = 2.0)
pcens_cdf.pcens_pgamma_dunif      # closed form
pcens_cdf.pcens_plnorm_dunif      # closed form
pcens_cdf.pcens_pweibull_dunif    # closed form
pcens_cdf.default                 # stats::integrate
  • d, p, q and r prefixes, so it reads like dgamma. The delay family is whichever p* function you hand it
  • S3 dispatch picks the closed form when the pair has one, and stats::integrate when it does not
  • The primary event distribution defaults to dunif

R/dprimarycensored.R:70, the roxygen example, reflowed · S3 methods at R/pcens_cdf.R:70, :106, :185, :267 · primarycensored

fitdistrplus

fnobjcens <- function(par, fix.arg, rcens, lcens, icens,
                      ncens, ddistnam, pdistnam)
{
  T1 <- -sum(do.call(ddistnam, c(list(ncens), as.list(par),
    as.list(fix.arg), list(log=TRUE))))
  # ... T2 and T3 the same shape ...
  p4 <- do.call(pdistnam, c(list(icens$right), as.list(par),
    as.list(fix.arg))) # without log=TRUE here
  p5 <- do.call(pdistnam, c(list(icens$left), as.list(par),
    as.list(fix.arg))) # without log=TRUE here
  T4 <- -sum(log(p4 - p5))
  idx <- is.infinite(T1) | is.infinite(T2) |
    is.infinite(T3) | is.infinite(T4)
  ifelse(sum(idx) > 0, .Machine$integer.max,
    (T1+T2+T3+T4)/data.size)
}
# what the user actually calls
fitdistcens(my_doulbe_censored_data,
            distr = "gamma")
# fitdistrplus then optimises fnobjcens
  • A distribution is the string "norm", with d and p pasted on the front and looked up by name
  • Three copies of this censored objective in mledist.R, branching on log support and on weights
  • To reach it, primarycensored invents a distribution called pcens_dist and hides two closures in an empty environment for the name lookup to find

fitdistrplus 1.2-6, R/mledist.R:188-198, CRAN source, reflowed with two terms cut · the wrapper is primarycensored/R/fitdistdoublecens.R:202-236

Stan, a complete duplication of the code base

R Stan
Daily probability dprimarycensored primarycensored_lpmf
Three closed forms pcens_cdf.R primarycensored_analytical_cdf.stan
Numerical fallback stats::integrate ode_rk45
Growth-rate primary expgrowth.R expgrowth.stan
  • 1,215 lines of Stan next to 2,703 lines of R
  • The R lookup table names 25 delay families. dist_lcdf in Stan has 18 branches, because Stan has no CDF for the other seven
  • Every fix lands twice. test-stan-dist_lcdf.R is 246 lines whose job is checking that the two halves still agree

wc -l over inst/stan/ and R/*.R at 2ec4f40, 25 June 2026 · data-raw/distributions.R:1-30 · inst/stan/functions/primarycensored_ode.stan:52-77

The Stan logo

integrate_1d

Error in function tanh_sinh<double>::integrate: The
tanh_sinh quadrature evaluated your function at a
singular point and got -inf. Please narrow the bounds
of integration or check your function for
singularities.

Warning: 243 chain(s) finished unexpectedly!
  • Post-warmup, in sampling. 243 of 256 chains gone, and the ones that lived returned the right answer
  • integrate_1d errors rather than rejecting, so the chain dies instead of the proposal

Error text and chain counts quoted from epinowcast/primarycensored#34, 5 September 2024

Eight days, then ode_rk45

  • xc, Stan’s high-precision distance argument, turned singularities into an integral that would not solve at all
  • Then d from parameter to data, integrating over the delay instead of the window, a guard on tiny delays, and the tolerance from 1e-6 to 1e-2. Twelve per cent at best
  • The recast to an ODE worked because the Stan devs had recently reworked AD support—not because it is a good or bad approach
// before
integrate_1d(primary_censored_integrand, ...)
// after
ode_rk45(primary_censored_ode, y0, ...)[1, 1]

Every run is one Sam Abbott reported in epinowcast/primarycensored#34 between 5 and 12 September 2024, read back from the issue, scripts/delays-stan-failures.py · the recast is PR #64, commit 1c4e6f1. The runs are not one dataset, and the scenario was made harder in the same PR that landed the ODE

Horizontal bar chart of ten runs reported in epinowcast/primarycensored issue 34. The first, integrate_1d at tolerance 1e-6, lost 243 of 256 chains. Eight tuning attempts sit between 6 and 27 per cent, the lowest of them a model with the truncation adjustment removed, which is not a valid fit. The last, recast as ode_rk45, lost 0 of 600.

pcd_load_stan_functions()

pcd_load_stan_functions(
  functions = "primarycensored_lpmf",
  wrap_in_block = TRUE,
  dependencies = TRUE,
  write_to_file = TRUE
)
/**
 * Vendored primary event censored distribution functions
 *
 * AUTO-GENERATED by inst/dev/vendor-primarycensored.R from
 * the installed primarycensored package (version 1.5.1)
 * Do not edit by hand; rerun the vendoring script instead.
 */
  • A downstream model cannot depend on the package. It has to physically contain the text of the functions
  • So the copying code, R/pcd-stan-tools.R, is 453 lines of R that reads Stan source, builds a call graph and sorts it topologically. It counts braces by hand
  • epinowcast carries a 521 line copy with its own licence file beside it. epidist splices the whole 1,103 line bundle in at build time

Call from primarycensored/vignettes/using-stan-tools.Rmd:82 and :106-111, the two examples combined · R/pcd-stan-tools.R:339-345 · epinowcast/primarycensored#171, shipped in PR #262, commit d7d710f · epinowcast/inst/stan/functions/primarycensored.stan:1-8 · epidist/R/marginal_model.R:377, and 1,103 lines is what pcd_load_stan_functions() returns at 2ec4f40 · primarycensored · epinowcast · epidist

Extending brms into epidist. Ick, hard

epidist_family_param.default <- function(family, ...) {
  data_dummy <- data.frame(y = c(1, 2))
  dummy_mdl <- make_stancode(
    y ~ 1, data = data_dummy, family = class(family)[1]
  )
  family_name <- tolower(class(family)[1])
  lpdf_pattern <- paste0(
    "target \\+= ", family_name,
    "_(lpdf|lpmf)\\(Y \\| (.+?)\\)"
  )
  lpdf_match <- regexpr(lpdf_pattern, dummy_mdl)
  # ... read the parameter order out of the match ...
}
  • Delays vary by district, by age and by wave. brms, by Paul-Christian Bürkner, is one way (via Stan) to get partial pooling and time-varying effects in R
  • Every brms family has a mu. Stan’s gamma_lpdf takes the shape first, so the intercept we read as the mean was the shape
  • To learn what order brms used, epidist compiles a dummy model, runs a regex over the generated Stan and reads the order back out
  • That string is then gsub-ed into our own Stan template, with dist_id and three more. Five passes before anything compiles

epidist/R/family.R:83-100 and R/marginal_model.R:332-368 · epinowcast/epidist#226, August 2024 · epidist · brms

Can we do better?

The Julia language logo

The good: multiple dispatch

using CensoredDistributions, Distributions, Turing

@model function delay_model(values, weights)
    α ~ truncated(Normal(1, 2), 0, Inf)
    θ ~ truncated(Normal(1, 2), 0, Inf)
    d = double_interval_censored(
        Gamma(α, θ); upper = 15, interval = 1
    )
    values ~ weight(d, weights)
end

chain = sample(delay_model(values, weights),
               NUTS(), MCMCThreads(), 1000, 2)

# the CDF dispatch behind the scene
primarycensored_cdf(d, u, x, ::AnalyticalSolver) =
    primarycensored_cdf(d, u, x, NumericSolver())
  • Each adjustment is a wrapper on a Distributions.jl object. primary_censored is the \(w_P\) integral, upper extends Distributions.jl’s truncated at \(C\), interval_censored is \(w_S\)
  • The stack is still a distribution, so Turing takes it as it is
  • A new distribution would just work. For example, cdf(primary_censored(Frechet(2, 1), Uniform(0, 1)), 3.0) returns 0.847
  • In Stan Frechet needs a branch, an integer and a rebuild

Model from CensoredDistributions.jl/README.md:67-81, run for this talk against v0.2.22 · CensoredDistributions.jl · Distributions.jl · Turing

The bad: automatic differentiation

# the Gamma closed form
MethodError: no method matching
  _gamma_inc(::Dual, ::Float64, ::Int64)

# and once a rule was written for it
Enzyme forward -> [-0.21736, -0.21296, 0.19054]
ForwardDiff    -> [-0.23692, -0.21296, 0.19054]
  • SpecialFunctions leaves the shape-parameter partial of gamma_inc not implemented, so our Gamma closed form would not differentiate
  • Adaptive quadrature also errored under auto-differentiation
  • Lifting a correct ChainRulesCore rule into Enzyme returned a wrong shape partial and two right ones, with no error

EpiAware/CensoredDistributions.jl#217 and #259, April and May 2026 · src/integration/integration.jl:14-20

2,724 lines of tests and 408 lines of rules

Autodiff backend Broken
ForwardDiff 0
ReverseDiff, tape 0
Mooncake forward 3
Mooncake reverse 3
Enzyme forward 4
Enzyme reverse 8

Scenarios registered broken out of 63, in test/ADFixtures/src/ADFixtures.jl:177 at acb9e39b, a working branch, read 12 August 2026. The released v0.2.22 carries 32 scenarios and registers none broken

  • 63 scenarios and a CI workflow per backend. DifferentiationInterfaceTest, by Guillaume Dalle and Adrian Hill, is what made any of it testable, and it is good
  • The 408 are for one Gamma CDF derivative, across gamma_ad.jl and five autodiff extensions
  • The default solver is GaussLegendre(; n = 64), because fixed nodes trace through every backend. I got there by hacking around the integral in sad confusion

Line counts at acb9e39b over test/ad/ with test/ADFixtures/src/, and over src/utils/gamma_ad.jl with the five autodiff files in ext/ · six per-backend workflows in .github/workflows/ · DifferentiationInterfaceTest

Guillaume Dalle, an author of DifferentiationInterfaceTest.jl

Guillaume Dalle, with Adrian Hill, from the DifferentiationInterfaceTest Project.toml, read 2026-08-13.

There is no brms in Julia

  • brms exists partly because in R you cannot easily drop the delay machinery into a model you wrote yourself (without learning Stan and then using code vendoring). In Julia you can
  • So perhaps most people do not need a formula interface. They write the model and the composed distribution goes in
  • None of our users know Julia
  • They need something really easy and a “wow” moment 😮
  • If Julia had something like brms it would very likely be a lot more extensible and flexible

Comparing across implementations

R and Stan Julia
Adding a distribution New branch, new integer, rebuild Pass the type
Distributions covered 18, listed in a switch Any univariate one
Choosing one An integer, set before sampling The object itself
Sharing the code Splice 1,103 Stan lines in at build Project.toml
The integral integrate_1d, then an ODE recast 64 fixed nodes
Worst failure 243 of 256 chains died 8 scenarios broken on one backend
Flexible component system brms composed Julia epidist

Comparison as summarised by @seabbs-bot

Who is using which packages?

R Julia
Downloads, all time 11,294 122
Downloads, last month 538 6

CRAN and JuliaPkgStats, 12 August 2026. CRAN counts requests to one mirror, JuliaPkgStats counts unique IP addresses, so the ratio is an upper bound. Both include continuous integration, so neither is a count of people. First CRAN release 28 October 2024, registered in General 2 August 2025.

  • Nine repositories on GitHub depend on primarycensored, two of them on CRAN. Seven are from my own orgs. Two are CDC’s, both as suggests
  • This means multiple public-health agencies use it as well as researchers
  • For example, the NEJM correspondence on Bundibugyo virus disease (DRC 2026) used epidist, and therefore primarycensoreddoi:10.1056/NEJMc2608070
  • Everything that depends on CensoredDistributions.jl is mine, Sam Brand’s or Sebastian Funk’s

GitHub code search, primarycensored in DESCRIPTION and CensoredDistributions in Project.toml, each file then read, 12 August 2026. primarycensored · CensoredDistributions.jl

In my view the Julia version is better

  • Any distribution, one implementation
  • It is also the one nobody uses (well, I do)
  • However, I would still point an R user at primarycensored today
  • We have not benchmarked yet, so the Julia version maybe (and likely is) less efficient/stable than the Stan version
  • So how do I get people to shift?

Thank you

Important

If you have moved a user base from one language to another, tell me how it went.