Composable probabilistic models can lower barriers to rigorous infectious disease modelling

Sam Abbott

London School of Hygiene & Tropical Medicine

14 August 2026

Talk plan

  • Modelling evidence must be timely, rigorous, and collaborative
  • Chaining loses information, joint models cannot be taken apart
  • What is composable modelling?
  • What we want from any approach
  • ComposableTuringIDModels.jl
  • EpiNow2
  • Turing.jl
  • ConvolvedDistributions.jl
  • Distributions.jl
  • Is Julia the place for this work?

samabbott.co.uk/JuliaCon2026/composable

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

Timely, rigorous, and collaborative evidence

  • Current approaches struggle to be timely, rigorous, and collaborative all at once
  • Multi-model efforts synthesise expertise by combining forecasts from multiple teams
  • Can improve predictive accuracy but teams rarely collaborate on model components
  • Common components would enable attribution of differences to assumptions rather than implementation

Cramer et al. (2022), doi:10.1038/s41597-022-01517-w

Analysing data and processes separately

  • ONS COVID-19 Infection Survey: 4M+ swabs, 150K households, £500M+, April 2020 to March 2023
  • External modellers accessed only summarised prevalence, not underlying observations
  • Prevalence to incidence to \(R_t\)
  • Cascade fed into policy-relevant analyses of variant transmissibility and severity
  • Uncertainty from earlier estimates approximated at each step
  • Modelling assumptions inherited without the ability to evaluate their impact

Four panels of the survey chain fitted as one model

Prevalence (A), incidence (B), antibody prevalence (C) and \(R_t\) (D) fitted together as one model. Abbott and Funk (2022), doi:10.1101/2022.03.29.22273101. Survey figures from the paper’s introduction

Analysing all data and processes together

  • Lison et al. found biases at each step: lack of epidemic phase in date imputation and count model assumptions in nowcasting led to biased \(R_t\)
  • Joint modelling of all steps together avoided these cascading biases
  • But models are difficult to develop within policy-relevant timelines
  • Require expertise across multiple domains and careful specification of component interactions
  • In 2022 existing COVID-19 models lacked the flexibility to adapt to mpox; new single-source models were built instead

Lison et al. (2024), doi:10.1371/journal.pcbi.1012021 · Endo et al. (2022), doi:10.1126/science.add4507

Title of the Lison et al. 2024 paper

What we want from any approach

  • Uncertainty and inference. Joint and staged inference that avoids the cascading biases, and automatic differentiation for fitting
  • Model structure. Components separated by process type, and models nested inside models
  • Workflow. One specification that is useful for simulation and for inference

Warning

These design considerations were developed by the authors and have not yet received broader community input.

Table 1 of the paper, requirements against motivating problems

Table 1 of the paper pairs each requirement with the problem that motivates it, under six themes. All twelve are at epiaware.org/approaches

Being able to follow a workflow

A nine step workflow for infectious disease modelling, from research questions through process and observation DAGs, modularisation, inference choices, implementation, validation and data integration, with feedback arrows running backwards from later steps to earlier ones.

What is composable modelling?

Components can be reused across contexts and combined in different configurations whilst maintaining statistical rigour.

Four applications drawn as graphs over a band of shared parts

Four illustrative applications, not implementations. The incubation period model appears in all four, the latent infection model in three, and the band beneath is what those shared components are themselves made of. Figure 1 of the paper, which is in CDC clearance. The design considerations behind it are at epiaware.org/approaches

Inspired by

  • SpeedyWeather.jl
  • HydroModels.jl
  • Comrade.jl

Domain-specific Julia ecosystems, from the roadmap talk’s inspiration deck · github.com/SpeedyWeather/SpeedyWeather.jl · github.com/HydroModels/HydroModels.jl · github.com/ptiede/Comrade.jl

Two approaches, ComposedDistributions.jl and ComposableTuringIDModels.jl

  • Composed distributions. Model the relationships between events with Distributions.jl
  • Those relationships form an event tree
  • Composable Turing models. A library of parts over Turing.jl. Transmission, latent process and observation are separate objects that nest into one model

Wording and design considerations from epiaware.org/approaches

ComposableTuringIDModels.jl

  • The proof of concept, a library of parts over Turing.jl
  • Three kinds of part. An infection model, an observation model, and prior models nested in the slots of both
  • Parts carry their own priors
  • A model definition does not know about the data. One definition simulates and fits
  • Adding a new application means defining only the novel parts

Sam Abbott Samuel Brand

Photos from GitHub, read 2026-08-13 · Registered in General at v0.1.1 · github.com/EpiAware/ComposableTuringIDModels.jl · Abbott et al., Composable probabilistic models can lower barriers to rigorous infectious disease modelling, in CDC clearance

Four applications drawn as graphs over a band of shared parts

Four illustrative applications, not implementations. The incubation period model appears in all four and the latent infection model in three

Each component is a struct

struct IDModel{I, O} <: AbstractComposableModel
    infection_model::I   # process → I_t
    observation_model::O # I_t → y_t
end

struct AR{D, I, P, E, F} <: AbstractLatentModel
    damp::D
    init::I
    p::P
    ϵ_t::E              # an innovation slot
    transform::F
end

struct NegativeBinomialError{S} <: AbstractObservationErrorModel
    cluster_factor::S
end

Struct fields abridged, parameter bounds dropped · src/latent_models/models/AR.jl and src/observation_models/ObservationErrorModels/NegativeBinomialError.jl, main at 011169f

  • A component is a plain struct. The type tree does the bookkeeping
  • A slot holds a fixed prior or another component
  • Use Accessors.jl to update a component

docs/src/design.md:18-20

ARIMA(2,1,1)

ar2 = AR(;
  damp = [truncated(Normal(0.2, 0.2), 0, 1),
          truncated(Normal(0.1, 0.05), 0, 1)],
  ϵ_t = HierarchicalNormal(std = HalfNormal(0.1)))

ma1 = MA(;
  θ = [truncated(Normal(0.0, 0.2), -1, 1)],
  ϵ_t = HierarchicalNormal(std = HalfNormal(0.1)))

# the AR's innovation slot now holds the MA process
arma21 = @set ar2.ϵ_t = ma1
arima211 = DiffLatentModel(arma21, Normal(0, 0.2); d = 1)

Run against main at 011169f for this talk · @set is Accessors.jl, not ours · Keywords track main. The paper’s artefact pins an earlier release, where these were damp_priors, θ_priors and std_prior

MA(1) inside AR(2) inside ARMA inside ARIMA, then Poisson

::::

as_turing_model

# AR draws its damp, init, and innovations as submodels
@model function as_turing_model(model::AR, n::Int)
    ar_init ~ as_turing_submodel(model.init, p; prefix = true)
    damp_AR ~ as_turing_submodel(model.damp, p; prefix = true)
    ϵ_t ~ as_turing_submodel(model.ϵ_t, n - p)
    ar = accumulate_scan(ARStep(reverse(damp_AR)),
                         ar_init, ϵ_t)
    return ar
end

# DiffLatentModel draws its init, then descends into the AR
@model function as_turing_model(model::DiffLatentModel, n::Int)
    latent_init ~ as_turing_submodel(model.init, d; prefix = true)
    diff_latent ~ as_turing_submodel(model.model, n - d)
    return _combine_diff(latent_init, diff_latent, d)
end

src/latent_models/models/AR.jl:99-115 and src/latent_models/modifiers/DiffLatentModel.jl:61-68, reformatted to fit, main at 011169f, comments and @asserts removed · 61 as_turing_model definitions across 45 files in src/, counted 2026-08-12

  • Every part implements as_turing_model
  • A part that holds another part draws it as a submodel, so the call descends through the slots
  • mdl is a DynamicPPL.Model; it simulates and fits from one definition

AR passes each slot to as_turing_submodel; DiffLatentModel passes its whole inner model, and the call descends from there

as_turing_submodel

# a component writes its parameter once
damp ~ as_turing_submodel(model.damp, p; prefix = true)

# what is passed to the call picks the method
as_turing_submodel(m, args...; prefix = false) =
    to_submodel(as_turing_model(m, args...), prefix)

as_turing_submodel(d::Distribution, ::ModelShape;
    prefix = false) = d

Two of the three as_turing_submodel methods, src/base/priors.jl:65-82, reformatted to fit and with the ::Bool annotations dropped. The third takes a vector of priors · The call line is src/base/priors.jl:25 · The AR example is src/latent_models/models/AR.jl:40-43

  • A call takes a fixed prior or a whole process
  • You can pass a Distribution or another Turing model
  • AR(damp = Normal(...)) is a constant coefficient, AR(damp = RandomWalk()) a time-varying one
  • The component that makes the call is not changed either way

Some fun bits and bobs

  • Split / StrataMap — split one expected series into several named observation streams; parallel, cascade, and strata-split pipelines
  • MixingStep — a mixing model drawn before the scan, so a fixed or inferred coupling matrix plugs into a Renewal
  • ConcatLatentModels — switch a latent process between segments of a series
  • Composed renewal processes, e.g. RenewalStep with folded-in modifiers
# split into streams, one per outcome
Split((cases = PoissonError(),
       deaths = NegativeBinomialError()))

# mix regions through an inferred coupling matrix
Renewal(; mixing = MixingStep())

# switch latent processes at a breakpoint
ConcatLatentModels([Intercept(Normal(2, 0.2)), AR()])

# wrap an observation stream in a delay
LatentDelay(PoissonError(), pmf)

# add day-of-week ascertainment
Ascertainment(PoissonError(), 7)

src/observation_models/Split.jl:179, src/steps/MixingStep.jl:42, src/latent_models/manipulators/ConcatLatentModels.jl:29, src/steps/RenewalStep.jl:166, and src/observation_models/modifiers/LatentDelay.jl, main at 011169f, signatures abridged

Real-time nowcasting (EpiNow2)

  • Reuses ARIMA(2,1,1) from earlier, swapping the Poisson observation model for negbin
  • broadcast_weekly wraps ARIMA to produce piecewise constant weekly \(R_t\) values
  • ascertainment_dayofweek adds day-of-week reporting effects via softmax transformation
  • Two LatentDelay wrappers compose incubation and reporting delays sequentially by nesting structs

Important

No new components needed and took ~ 3 hours

Figures 2 and 4 of the paper. Replicating a common configuration of EpiNow2, real-time estimation accounting for reporting delays, right truncation and day-of-week effects. Abbott et al. (2020), doi:10.12688/wellcomeopenres.16006.2. Daily COVID-19 cases from Italy, February to June 2020, shipped with EpiNow2. One configuration replicated, not a benchmark against the package

The EpiNow2 configuration drawn as a graph of components

Six panels: prior checks on each part, then the joint fit

Turing.jl

  • Turing.jl gave us submodels and a choice of samplers
  • Eleven breaking releases in nineteen months, v0.35.5 to v0.46.0
  • Large simulated counts error, and vectors mixing missing and observed values have to be handled by hand
  • Future automatic differentiation support is uncertain

The Turing.jl logo, three overlapping density curves

From the Turing.jl documentation

“not all AD libraries in there are thoroughly tested on Turing models. Thus, it is possible that some of them will either error … or maybe even silently give incorrect results”

Release dates from the GitHub API, 2026-08-10 · v0.35.5 is the release the paper’s artefact pins · turinglang.org/docs/usage/automatic-differentiation · The two practical limits are in the paper’s discussion · Logo from turinglang.org, read 2026-08-13

The teams are small — stats from @seabbs-bot

  • Enzyme.jl had 18 human authors in the last six months, 210 of its 276 commits from William Moses. Mooncake.jl had 12, with 155 of 221 from Hong Ge
  • TuringLang/ADTests published a per-backend support table for Turing models. It is archived, with no commits since May
  • The work itself is good

Penelope Yong of the TuringLang team Hong Ge of the TuringLang team Shravan Goswami of the TuringLang team Xianda Sun of the TuringLang team

ComposedDistributions.jl

  • Write the epidemiology as distributions
  • The probabilistic programming language becomes optional
  • A verb grammar for composition over any Distributions.jl distribution: chains and branches
  • A composed object scores an observed record and simulates a new one
  • Parameter uncertainty is an ordinary leaf, not a PPL-specific layer

Sam Abbott seabbs-bot

Photos from GitHub, read 2026-08-13

cfr = 0.12

admission = @uncertain compose((
    path = sequential(
        :onset_admit => LogNormal(Normal(0.0, 0.2), 0.4),
        :admit_outcome => resolve(
            :death => (Gamma(1.5, 1.0), cfr),
            :discharge => Gamma(2.0, 1.5))),
    onset_report = truncated(Gamma(1.5, 1.0); upper = 21.0),
    onset_referral = censored(Gamma(1.0, 2.0); upper = 14.0)))

A hospital pathway from the ComposedDistributions.jl README, main checked 2026-08-13 · sequential chains delays and resolve picks an outcome with the given probability

Where we are

  • ComposedDistributions.jl — the verb grammar above
  • CensoredDistributions.jl — interval and primary-interval censoring, plus Distributions.jl’s truncation
  • ConvolvedDistributions.jl — convolution and quadrature
  • ModifiedDistributions.jl — transforming distributions
  • ReparameterisedDistributions.jl — reparameterising distributions
  • DistributionsInference.jl — the interface between our composed distributions and inference approaches

Distributions.jl

  • A small team maintains the interface everything above leans on
  • There is no concrete written definition of the Distributions.jl interface
  • Not great automatic differentiation support
  • Many experimental Julia PPLs end up implementing their own
  • Should we aim to be more generic: anything with a limited set of methods, e.g. rand and logpdf?

Is Julia the place for this work?

  • JAX has one automatic differentiation system, and everything is written against it
  • NumPyro also supports submodels and nesting models
  • We are trying to support seven automatic differentiation configurations in CI, and cannot tell a user which one is safe for a composed model
  • I have never seen a community that throws around the word “composable” as much as the Julia community does (5+ talks here)

Important

I have argued myself into both answers.

The Julia language logo The JAX logo The Enzyme logo The Mooncake logo

Thank you

Important

Tell me what you would build this on, if not a probabilistic programming language.

  • Where has Julia already pulled composition off? Point me at composable pattern libraries, composable model ecosystems, or bits of the ecosystem that fit together cleanly — we want to learn from what already works before building our own
  • AlgebraicJulia. Exploring whether a formal, computable notion of composition could give us guarantees instead of good intentions
  • How do you sell Julia? We are trying to move epidemiological modellers off R and Stan. What has actually worked for you?
  • The prompts that wrote these decks