From R/Stan to Julia
London School of Hygiene & Tropical Medicine
14 August 2026
primarycensored, and what shipping the same likelihood in Stan costbrms and epidistepinowcastsamabbott.co.uk/JuliaCon2026/delays
JuliaCon 2026, Muschel — N3, Friday 14 August 2026, 14:45.
Diagram for this talk, scripts/delays-double-censoring.py
Fig 3 of Charniga et al. (2024), PLOS Comput Biol, CC-BY 4.0
\[ 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.
\[ \Pr\!\big(T \in [n, n + w_S]\big) = F_{\mathrm{PEC}}(n + w_S) - F_{\mathrm{PEC}}(n) \]
Diagram from How to serial interval?
Note
In the past this was often written as a double integral, which is typically more complicated to evaluate.
\[ L^{\mathrm{RT}} = \frac{F_{\mathrm{PEC}}(n + w_S) - F_{\mathrm{PEC}}(n)} {F_{\mathrm{PEC}}(C - P)} \]
Diagram from How to serial interval?
LogNormal(1.6, 0.6) with daily windows, computed for this talk with CensoredDistributions.jl v0.2.22, scripts/delays-right-truncation.jl
primarycensored wraps any delay family in the three adjustmentsR/fitdistdoublecens.R:93-103, the roxygen example, reflowed · primarycensored
Note
Stan has no import statement, so the 1,081 line function bundle is spliced into the downstream model as text.
epinowcast/primarycensored#34 and PR #64, commit 1c4e6f1, September 2024. The scenario was made harder in the same PR, so the 600 chain runs are the like for like pair
real dist_lcdf(real delay, array[] real params,
int dist_id) {
if (dist_id == 1)
return lognormal_lcdf(delay | params[1], params[2]);
else if (dist_id == 2)
return gamma_lcdf(delay | params[1], params[2]);
// ... 15 more branches ...
else if (dist_id == 25)
return von_mises_lcdf(delay | params[1], params[2]);
else reject("Invalid distribution identifier: ",
dist_id);
}dist_id = 3 meant Normal in Stan and Weibull in R. Ask for a Weibull on the numerical path and you fitted a normalprimarycensored_ode.stan:38-51 and :52-77 · epinowcast/primarycensored#277, commit 4aaf249, February 2026
brms metaprograms Stan and hands you the whole regression apparatus, so epidist puts the censored likelihood behind a brms formulaNote
The delay family has to be one brms already knows.
brms took more than a formulafamily.R, formula.R, stancode.R and prior.R exist only to bend brms around a censored likelihood. 774 lines with marginal_model.Repidist_family_model to epidist_family_priorgsub() passes over a templateepidist/R/marginal_model.R:339-348, one of five passes at :332-368 · epidist
brmsbrms exists partly because in R you cannot drop the delay machinery into a model you wrote yourselfDistributions.jl object. It goes into any model somebody writes, with no formula interface and no code generationNote
Where is a formula interface still needed, once the components compose on their own?
Composing components rather than generating models is the 16:45 talk in this room, samabbott.co.uk/JuliaCon2026/composable
epinowcast writes the delay as discrete-time reporting hazards, so effects and random walks can modify it by delay and by dateprimarycensored and epidist. One backend the whole way upThe count reported at delay \(d\)
\[ \mathbb{E}[n_{t,d}] = \lambda_t\, h_d \textstyle\prod_{d'<d}(1-h_{d'}) \]
Note
The README says the default lognormal reporting delay can fail on multimodal delays.
using CensoredDistributions, Distributions, Turing
@model function double_censored_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
model = double_censored_model(values, weights)
chain = sample(model, NUTS(), MCMCThreads(), 1000, 2)Model code from README.md:71-85, run for this talk against v0.2.22 · CensoredDistributions.jl
primary_censored is the \(w_P\) integral, upper truncates at \(C\), interval_censored is \(w_S\). double_interval_censored stacks all three in that ordercdf(primary_censored(Frechet(2, 1), Uniform(0, 1)), 3.0) returns 0.847. Nobody wrote that method| R | Julia | |
|---|---|---|
| Downloads, all time | 11,244 | 116 |
| Downloads, last month | 528 | 49 |
CRAN and JuliaPkgStats, 10 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.
primarycensored, two of them on CRAN. Seven are from my own orgs. Two are CDC’s, both as suggestsCensoredDistributions.jl is mine, Sam Brand’s or Sebastian Funk’s. I looked for one from outside and did not find itNote
Twenty-one months on CRAN against twelve in General. I do not know how much of the gap is R having the users and how much is the package.
ConvolvedDistributions.jl adds sums and differences of delaysepidist, with Turing submodels doing what the brms formula does nowImportant
The epidist layer on top is not written yet. Open issue CensoredDistributions.jl#749 says the joint fit is too slow.
Diagram from How to serial interval?, labelled there for a serial interval · Plain Distributions.jl dispatch, composed for this talk rather than lifted from a README. Run against CensoredDistributions.jl v0.2.22 and ConvolvedDistributions.jl. Neither package appears in the other’s [deps], and both list Distributions
Important
Tell me about a delay you cannot write down as a composition.
ConvolvedDistributions.jl, DistributionsInference.jl and the restsamabbott.co.uk/JuliaCon2026/delays
Sam Abbott, LSHTM · epiaware.org · github.com/seabbs
primarycensored_cdf(dist::Gamma, primary_event::Uniform,
x::Real, ::AnalyticalSolver)
primarycensored_cdf(dist::LogNormal, primary_event::Uniform,
x::Real, ::AnalyticalSolver)
primarycensored_cdf(dist::Weibull, primary_event::Uniform,
x::Real, ::AnalyticalSolver)
primarycensored_cdf(dist::D1, primary_event::D2,
x::Real, method::NumericSolver
) where {D1 <: UnivariateDistribution,
D2 <: UnivariateDistribution}UnivariateDistributionFrechet needs a new branch, a new integer and a rebuildprimarycensored_cdf.jl:184-201, :323, :373, :415, v0.2.22 · primarycensored_analytical_cdf.stan
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 runs are not one dataset. The scenario was made harder in the same PR that landed the ODE, so the last two rows on 600 chains are the pair closest to like for like.
Gamma for any UnivariateDistribution and the rest of the tree is unchangedReal output, CensoredDistributions.jl v0.2.22. Reflowed onto several lines, and the type parameters of the solver and of Truncated are elided at …
| 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
GaussLegendre(; n = 64) because fixed nodes trace through all six backends and adaptive ones do notSix per-backend workflows in .github/workflows/, over a shared ad-backend.yaml · why an ecosystem needs that CI was the 14:30 talk in this room, samabbott.co.uk/JuliaCon2026/roadmap
primarycensored, R and Stan |
CensoredDistributions.jl |
|
|---|---|---|
| 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,081 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 |
Important
The Stan file has to know every distribution it will ever support. The Julia file does not have to know any of them.
primarycensored_ode.stan:52-77 and pcd_functions.stan · primarycensored_cdf.jl:184-201, v0.2.22 · epinowcast/primarycensored#34 and PR #64, September 2024
Removed
0
tables left to keep in step
A class of bug. dist_id = 3 meant Normal in Stan and Weibull in R, so asking for a Weibull fitted a normal and returned numbers. The two integer tables agreed on lognormal, gamma and exponential, and on nothing else.
primary_censored(Weibull(2, 1), Uniform(0, 1)) carries the type. There is no second table for it to disagree with.
Moved
4 of 6
autodiff backends carry a broken scenario
The numerical problem, from the language to the autodiff backends. Six backends run in CI, each with its own workflow. Two pass every scenario, four carry scenarios registered broken, out of 63.
The default solver is GaussLegendre(; n = 64) because fixed nodes trace through every backend. Stan gave up adaptive quadrature for an ODE, Julia for a dot product.
Unchanged
100×
more downloads in R, all time
Who uses it. 11,244 downloads all time against 116, and 528 last month against 49.
Nine GitHub repositories depend on primarycensored, two of them on CRAN. Seven are mine, two are CDC’s. Everything depending on CensoredDistributions.jl is mine, Sam Brand’s or Sebastian Funk’s.
Twenty-one months on CRAN against twelve in General, so the gap is not all the package.
CRAN and JuliaPkgStats, 10 August 2026. CRAN counts requests to one mirror, JuliaPkgStats counts unique IP addresses, so the ratio is an upper bound · epidist/R/marginal_model.R:339-347