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}, Bool}, 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, Int64}[2, 2, 2, 1, 0, 0, 0, 0, 1, 0  …  0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_y_t = [3.3831271516466574, 3.044906224486632, 1.9888167594063646, 1.2906128911118944, 0.8375481080596769, 0.5462780912891562, 0.3569046385115774, 0.23350911640815994, 0.15262979280814262, 0.09976049334460445  …  0.0010256871941593398, 0.0006842237410396727, 0.00045598498378983556, 0.0003031939933640396, 0.00020241781969428755, 0.0001345503311412326, 8.934675774518192e-5, 5.9352088838894625e-5, 3.949981861573408e-5, 2.6280604020382287e-5], I_t = [3.3831271516466574, 3.044906224486632, 1.9888167594063646, 1.2906128911118944, 0.8375481080596769, 0.5462780912891562, 0.3569046385115774, 0.23350911640815994, 0.15262979280814262, 0.09976049334460445  …  0.0010256871941593398, 0.0006842237410396727, 0.00045598498378983556, 0.0003031939933640396, 0.00020241781969428755, 0.0001345503311412326, 8.934675774518192e-5, 5.9352088838894625e-5, 3.949981861573408e-5, 2.6280604020382287e-5], Z_t = [0.5579507401465119, 0.4526203697152922, 0.026690134216360684, -0.40573251948231415, -0.8381263088895851, -1.2654768426098513, -1.6911363863165563, -2.115383894618318, -2.540589678950194, -2.9658327667240143  …  -7.543242192121835, -7.948075321462567, -8.353900413693781, -8.761987449613143, -9.166026316874836, -9.574421954108926, -9.983835338560691, -10.392872976680566, -10.800064212490703, -11.207529114995411])

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  latent_init[1], latent_init[2], ar_init, damp_AR, ρ, std, ϵ_t[1],  │
│           ϵ_t[2], ϵ_t[3], ϵ_t[4], ϵ_t[5], ϵ_t[6], ϵ_t[7], ϵ_t[8], ϵ_t[9],    │
│           ϵ_t[10], ϵ_t[11], ϵ_t[12], ϵ_t[13], ϵ_t[14], ϵ_t[15], ϵ_t[16],     │
│           ϵ_t[17], ϵ_t[18], ϵ_t[19], ϵ_t[20], ϵ_t[21], ϵ_t[22], ϵ_t[23],     │
│           ϵ_t[24], ϵ_t[25], ϵ_t[26], ϵ_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, loglikelihood, logjoint        │
│                                                                              │
│ Summary                                                                      │
│          param     mean     std    mcse   ess_bulk  ess_tail    rhat  …      │
│   latent_init…   0.2143  0.5837  0.0252   537.3283  398.5904  1.0001  …      │
│   latent_init…  -0.1853  0.7318  0.0311   552.3372  483.8228  1.0004  …      │
│        ar_init  -0.4855  0.5688  0.0255   518.4010  395.5476  1.0008  …      │
│        damp_AR   0.0410  0.0312  0.0012   502.6466  341.2595  1.0007  …      │
│              ρ   0.0410  0.0312  0.0012   502.6466  341.2595  1.0007  …      │
│            std   0.0747  0.0578  0.0022   564.5858  392.3295  1.0062  …      │
│         ϵ_t[1]  -0.0938  1.0227  0.0330   967.4025  477.3459  1.0027  …      │
│         ϵ_t[2]  -0.0572  0.9533  0.0337   814.0101  592.9734  0.9996  …      │
│         ϵ_t[3]  -0.0096  1.0091  0.0375   751.6606  332.2904  1.0017  …      │
│         ϵ_t[4]  -0.0208  0.9410  0.0358   680.4574  304.2566  1.0016  …      │
│         ϵ_t[5]  -0.0030  0.9753  0.0316   953.0170  653.7758  1.0014  …      │
│         ϵ_t[6]  -0.0782  1.0480  0.0318  1083.8486  563.0251  1.0007  …      │
│         ϵ_t[7]  -0.0896  0.9728  0.0344   797.7954  317.8816  1.0043  …      │
│         ϵ_t[8]  -0.1613  0.8985  0.0275  1042.8182  601.4149  1.0019  …      │
│         ϵ_t[9]  -0.0657  0.9838  0.0312   980.1175  443.8196  1.0156  …      │
│        ϵ_t[10]  -0.0318  0.9098  0.0301   904.0984  550.7320  1.0028  …      │
│        ϵ_t[11]  -0.0011  1.0410  0.0349   920.0724  532.2438  1.0362  …      │
│        ϵ_t[12]   0.0270  0.9361  0.0424   492.0724  201.0408  1.0007  …      │
│        ϵ_t[13]  -0.0276  1.0284  0.0350   864.8820  564.1153  1.0007  …      │
│        ϵ_t[14]  -0.0335  0.9583  0.0341   796.4005  652.0758  1.0041  …      │
│        ϵ_t[15]  -0.1180  0.9670  0.0327   845.1258  575.3035  1.0096  …      │
│        ϵ_t[16]  -0.0443  1.0466  0.0367   801.0280  422.2519  1.0012  …      │
│        ϵ_t[17]  -0.0244  1.0639  0.0364   852.2540  526.1526  1.0011  …      │
│        ϵ_t[18]   0.0187  0.9407  0.0289  1052.6816  693.2795  0.9995  …      │
│        ϵ_t[19]  -0.0137  1.0133  0.0301  1131.4570  665.8263  1.0006  …      │
│        ϵ_t[20]   0.0002  0.9645  0.0286  1146.0815  486.3383  1.0017  …      │
│        ϵ_t[21]   0.0330  0.9442  0.0413   556.0160  290.6345  0.9992  …      │
│        ϵ_t[22]   0.0805  1.1013  0.0336  1079.0167  519.4991  0.9992  …      │
│        ϵ_t[23]   0.0277  1.0332  0.0372   755.2522  554.9285  1.0085  …      │
│        ϵ_t[24]   0.0037  0.9618  0.0305   999.4109  495.6441  1.0128  …      │
│        ϵ_t[25]  -0.0166  1.0269  0.0346   909.4720  364.4678  1.0008  …      │
│        ϵ_t[26]   0.0202  1.0687  0.0354   921.2297  501.3851  1.0028  …      │
│        ϵ_t[27]   0.0230  1.0331  0.0303  1172.1007  583.2867  0.9991  …      │
│   init_incide…   0.3395  0.7167  0.0300   582.2983  528.8758  1.0048  …      │
╰──────────────────────────────────────────────────────────────────────────────╯

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 the NOTICE file for full attribution and a summary of the changes, and LICENSE for the Apache-2.0 terms.

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.