Skip to content

ComposableTuringIDModels.jl ​

A toolkit for composable probabilistic infectious disease modelling in Julia.

Why ComposableTuringIDModels? ​

  • Composable models: Assemble a model from interchangeable infection and observation parts — each infection model owning its own latent process — instead of writing one monolithic model.

  • Swap a part to change an assumption: Change the latent process, infection process, or observation model on its own to test how each assumption shapes your conclusions.

  • One interface: Every part becomes a Turing / DynamicPPL model through the single as_turing_model constructor, so parts nest freely as submodels. The full Turing inference toolbox (NUTS, prior simulation) applies.

  • Simulate and infer: Generate synthetic data from any model, then run Bayesian inference on the same model with real data.

  • A library of parts: Random walks, AR/MA/ARIMA latent processes, renewal and exponential-growth infection models, ODE (SIR/SEIR) processes, Poisson and negative-binomial observations, reporting delays, ascertainment, and aggregation — all interchangeable.

Getting started ​

You assemble a model from parts, and ComposableTuringIDModels turns the assembly into a single Turing model you can simulate from and fit. Each part is itself a model, joined through the generic as_turing_model constructor.

Compose a model: an ARIMA-style latent process (a differenced AR) folded into a direct-infections process, observed with Poisson error.

julia
using ComposableTuringIDModels, Distributions, Turing
model = IDModel(
    DirectInfections(;
        Z = DiffLatentModel(; model = AR(), init = [Normal(), Normal()]),
        initialisation = Normal()),
    PoissonError())
IDModel
├─ infection: DirectInfections
│  └─ Z: DiffLatentModel
│     └─ model: AR
│        └─ ϵ_t: HierarchicalNormal
└─ observation: PoissonError

Build a Turing model; missing observations simulate from the prior.

julia
n = 30
prior_model = as_turing_model(model, missing, n)
DynamicPPL.Model{typeof(ComposableTuringIDModels._as_turing_model_idmodel), (:model, :y_t, :n), (), (:y_t,), Tuple{IDModel{DirectInfections{DiffLatentModel{AR{Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Float64}, Distributions.Normal{Float64}, Int64, HierarchicalNormal{Float64, Distributions.Truncated{Distributions.Normal{Float64}, Distributions.Continuous, Float64, Float64, Float64}}, typeof(identity)}, Vector{Distributions.Normal{Float64}}}, typeof(exp), Distributions.Normal{Float64}}, PoissonError}, Missing, Int64}, Tuple{}, DynamicPPL.DefaultContext, false}(ComposableTuringIDModels._as_turing_model_idmodel, (model = IDModel, y_t = missing, n = 30), NamedTuple(), DynamicPPL.DefaultContext())

Sample from the prior and inspect the generated quantities.

julia
draw = rand(prior_model)
(; generated_y_t, I_t, Z_t) = prior_model()
(generated_y_t = Union{Missing, BigInt}[2, 4, 22, 131, 524, 2550, 12708, 62826, 283261, 1299435  …  39680678179927, 191985621800092, 1016859392033456, 5514526077736982, 30373158883753228, 162403997027493312, 854037625960284800, 4500778044268547584, 22131982065189990400, 119071532775459323904], expected_y_t = [1.1219383450913283, 5.06917798769595, 25.14500013338704, 119.40172143837421, 553.5113658885243, 2563.0609839034487, 12574.335451538978, 62798.737093368625, 282895.8349290275, 1.2993071319007038e6  …  3.96806887582363e13, 1.9198560432814225e14, 1.0168593617313888e15, 5.514526065354386e15, 3.037315894765604e16, 1.6240399706225482e17, 8.540376257558783e17, 4.500778042443006e18, 2.2131982065830048e19, 1.1907153276775206e20], I_t = [1.1219383450913283, 5.06917798769595, 25.14500013338704, 119.40172143837421, 553.5113658885243, 2563.0609839034487, 12574.335451538978, 62798.737093368625, 282895.8349290275, 1.2993071319007038e6  …  3.96806887582363e13, 1.9198560432814225e14, 1.0168593617313888e15, 5.514526065354386e15, 3.037315894765604e16, 1.6240399706225482e17, 8.540376257558783e17, 4.500778042443006e18, 2.2131982065830048e19, 1.1907153276775206e20], Z_t = [0.7456337527065965, 2.2537545698687733, 3.8552349729435846, 5.413069516276333, 6.946858184530082, 8.479533418049687, 10.069989044790336, 11.678266140290818, 13.183409932428555, 14.707917602903803  …  31.942461654027973, 33.519017405702726, 35.18607111304637, 36.87673800637258, 38.582911581972674, 40.25943933280263, 41.91932754400581, 43.58135785205951, 45.17413128661572, 46.856832000117016])

Condition on data and run inference.

julia
using StatsBase
posterior_model = as_turing_model(model, generated_y_t, n)
chain = sample(posterior_model, NUTS(), 1_000)
summarystats(chain)
╭─FlexiSummary (9 statistics) ─────────────────────────────────────────────────╮
│   iter    collapsed                                                          │
│   chain   collapsed                                                          │
│ ↓ stat  = [mean, std, mcse, ess_bulk, ess_tail, rhat, q5, q50, q95]          │
│                                                                              │
│ Parameters (34) ── AbstractPPL.VarName                                       │
│  Float64  init[1], init[2], diff.init, diff.damp, diff.ρ, diff.std,          │
│           diff.ϵ_t[1], diff.ϵ_t[2], diff.ϵ_t[3], diff.ϵ_t[4], diff.ϵ_t[5],   │
│           diff.ϵ_t[6], diff.ϵ_t[7], diff.ϵ_t[8], diff.ϵ_t[9], diff.ϵ_t[10],  │
│           diff.ϵ_t[11], diff.ϵ_t[12], diff.ϵ_t[13], diff.ϵ_t[14],            │
│           diff.ϵ_t[15], diff.ϵ_t[16], diff.ϵ_t[17], diff.ϵ_t[18],            │
│           diff.ϵ_t[19], diff.ϵ_t[20], diff.ϵ_t[21], diff.ϵ_t[22],            │
│           diff.ϵ_t[23], diff.ϵ_t[24], diff.ϵ_t[25], diff.ϵ_t[26],            │
│           diff.ϵ_t[27], init_incidence                                       │
│                                                                              │
│ Extras (14)                                                                  │
│  Float64   n_steps, is_accept, acceptance_rate, log_density,                 │
│            hamiltonian_energy, hamiltonian_energy_error,                     │
│            max_hamiltonian_energy_error, tree_depth, numerical_error,        │
│            step_size, nom_step_size, logprior                                │
│  BigFloat  loglikelihood, logjoint                                           │
│                                                                              │
│ Summary                                                                      │
│          param     mean     std    mcse   ess_bulk   ess_tail    rhat  …     │
│        init[1]  -0.9988  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│        init[2]   1.4132  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│      diff.init   1.0273  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│      diff.damp   0.2035  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│         diff.ρ   0.2035  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│       diff.std   0.0096  0.0000  0.0000  1000.0080  1000.0080  1.0000  …     │
│    diff.ϵ_t[1]   1.1884  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│    diff.ϵ_t[2]   1.8598  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│    diff.ϵ_t[3]  -0.4070  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│    diff.ϵ_t[4]   1.4690  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│    diff.ϵ_t[5]   0.4169  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│    diff.ϵ_t[6]  -0.0426  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│    diff.ϵ_t[7]   0.1987  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│    diff.ϵ_t[8]  -0.1326  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│    diff.ϵ_t[9]  -1.1374  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[10]  -0.9952  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[11]   0.1871  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[12]  -0.2294  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[13]   0.1444  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[14]  -1.5490  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[15]  -0.6433  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[16]   0.5979  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[17]  -0.3092  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[18]  -1.4087  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[19]  -0.7176  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[20]  -0.1561  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[21]  -1.9548  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[22]   1.8670  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[23]   0.0318  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[24]  -1.7096  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[25]  -1.8230  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[26]   1.5974  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   diff.ϵ_t[27]  -0.3036  0.0000  0.0000  1000.0080        NaN  1.0000  …     │
│   init_incide…  -0.8098  0.0000  0.0000  1000.0080  1000.0080  1.0000  …     │
╰──────────────────────────────────────────────────────────────────────────────╯

Swap a part ​

Because every part is interchangeable, the same swap-in/swap-out pattern applies throughout. Replace PoissonError() with NegativeBinomialError(), wrap the observation in a LatentDelay, or change the latent process — without touching the rest of the model.

Same infection process, two observation assumptions.

julia
latent = DiffLatentModel(; model = AR(), init = [Normal(), Normal()])
infections = DirectInfections(; Z = latent, initialisation = Normal())

poisson_model = IDModel(infections, PoissonError())
negbin_model = IDModel(infections, NegativeBinomialError())
IDModel
├─ infection: DirectInfections
│  └─ Z: DiffLatentModel
│     └─ model: AR
│        └─ ϵ_t: HierarchicalNormal
└─ observation: NegativeBinomialError

That is the point: you compare modelling assumptions by swapping parts, not by rewriting models.

  • ModifiedDistributions.jl modifies the behaviour of the underlying Distributions.jl distributions.

  • ReparameterisedDistributions.jl switches distributions between parameterisations, so a prior can be stated in whichever one is natural.

  • LoweredDistributions.jl lowers a distribution onto a backend-agnostic dynamical-systems representation, so a delay distribution and a compartmental model become two views of one thing.

  • Turing.jl is the probabilistic programming backend every model here compiles down to.

Where to learn more ​

Adapted from ​

The modelling code in this package is ported and adapted from the open-source, Apache-2.0 licensed EpiAware package (CDCgov/Rt-without-renewal, ported from the fork seabbs/Rt-without-renewal). ComposableTuringIDModels is a modified, derived work: it has been renamed, re-architected around the generic as_turing_model constructor, and upgraded to build against the latest Turing.jl. See NOTICE for attribution and LICENSE for the Apache-2.0 terms. The commit history is the record of what changed.

Part of the EpiAware ecosystem ​

ComposableTuringIDModels is part of EpiAware, a set of composable tools for infectious disease modelling. See the other packages in the ecosystem.

Contributing ​

We welcome contributions and new contributors! Please open an issue or pull request on GitHub. This package follows ColPrac and is formatted with Runic.

How to cite ​

If you use ComposableTuringIDModels in your work, please cite it. Citation metadata lives in CITATION.cff, which GitHub renders as a "Cite this repository" button on the repository page.

Code of conduct ​

Please note that the ComposableTuringIDModels project is released with a Contributor Code of Conduct. By contributing, you agree to abide by its terms.

License ​

Apache License 2.0. See LICENSE and NOTICE.