MRC Centre for Global Infectious Disease Analysis, Imperial College London
2025-09-24
odin created to integrate ODEs (e.g. for compartmental models) in R with a domain-specific language (DSL)
mcstate for statistical machinery (particle filter, pMCMC)dust for efficient parallel simulationodin.dust compile odin code to use dustmonty
odin models, but usable on its ownmcstate
monty models can be built from
odin and dataWe will show:
odin2 to write and compile some simple modelsdust2 to simulate from thesemonty to fit these to dataWe have some data on the daily incidence of cases
odin\[\begin{gather*} \frac{dS}{dt} = -\beta S \frac{I}{N}\\ \frac{dI}{dt} = \beta S \frac{I}{N} - \gamma I\\ \frac{dR}{dt} = \gamma I \end{gather*}\]
Things to note:
initial and deriv pairodin2dust2The output has dimensions number of states x number of timepoints
Output can be nicely unpacked into the different states using dust_unpack_state
update(S) <- S - n_SI
update(I) <- I + n_SI - n_IR
update(R) <- R + n_IR
initial(S) <- N - I0
initial(I) <- I0
initial(R) <- 0
p_SI <- 1 - exp(-beta * I / N * dt)
p_IR <- 1 - exp(-gamma * dt)
n_SI <- Binomial(S, p_SI)
n_IR <- Binomial(I, p_IR)
N <- parameter(1000)
I0 <- parameter(10)
beta <- parameter(0.2)
gamma <- parameter(0.1)\[\begin{gather*} S(t + dt) = S(t) - n_{SI}\\ I(t + dt) = I(t) + n_{SI} - n_{IR}\\ R(t + dt) = R(t) + n_{IR} \end{gather*}\]
dt is a special parameterinitial and update pairupdate(S) <- S - n_SI
update(I) <- I + n_SI - n_IR
update(R) <- R + n_IR
initial(S) <- N - I0
initial(I) <- I0
initial(R) <- 0
p_SI <- 1 - exp(-beta * I / N * dt)
p_IR <- 1 - exp(-gamma * dt)
n_SI <- Binomial(S, p_SI)
n_IR <- Binomial(I, p_IR)
N <- parameter(1000)
I0 <- parameter(10)
beta <- parameter(0.2)
gamma <- parameter(0.1)odin2sir <- odin({
update(S) <- S - n_SI
update(I) <- I + n_SI - n_IR
update(R) <- R + n_IR
initial(S) <- N - I0
initial(I) <- I0
initial(R) <- 0
p_SI <- 1 - exp(-beta * I / N * dt)
p_IR <- 1 - exp(-gamma * dt)
n_SI <- Binomial(S, p_SI)
n_IR <- Binomial(I, p_IR)
N <- parameter(1000)
I0 <- parameter(10)
beta <- parameter(0.2)
gamma <- parameter(0.1)
})zero_everysir <- odin({
update(S) <- S - n_SI
update(I) <- I + n_SI - n_IR
update(R) <- R + n_IR
update(incidence) <- incidence + n_SI
p_SI <- 1 - exp(-beta * I / N * dt)
p_IR <- 1 - exp(-gamma * dt)
n_SI <- Binomial(S, p_SI)
n_IR <- Binomial(I, p_IR)
initial(S) <- N - I0
initial(I) <- I0
initial(R) <- 0
initial(incidence, zero_every = 1) <- 0
N <- parameter(1000)
I0 <- parameter(10)
beta <- parameter(0.2)
gamma <- parameter(0.1)
})sys <- dust_system_create(sir, pars = list(), dt = 1 / 128)
dust_system_set_state_initial(sys)
t <- seq(0, 20, by = 1 / 128)
y <- dust_system_simulate(sys, t)
y <- dust_unpack_state(sys, y)
plot(t[t %% 1 == 0], y$incidence[t %% 1 == 0], type = "o", pch = 19,
ylab = "Infection incidence", xlab = "Time")
lines(t, y$incidence, col = "red")timesir <- odin({
update(S) <- S - n_SI
update(I) <- I + n_SI - n_IR
update(R) <- R + n_IR
update(incidence) <- incidence + n_SI
seed <- if (time == seed_time) seed_size else 0
p_SI <- 1 - exp(-beta * I / N * dt)
p_IR <- 1 - exp(-gamma * dt)
n_SI <- min(seed + Binomial(S, p_SI), S)
n_IR <- Binomial(I, p_IR)
initial(S) <- N
initial(I) <- 0
initial(R) <- 0
initial(incidence, zero_every = 1) <- 0
N <- parameter(1000)
beta <- parameter(0.2)
gamma <- parameter(0.1)
seed_time <- parameter()
seed_size <- parameter()
})interpolateThe interpolate function in odin can be used for time-varying parameters, with specification of
interpolatesir <- odin({
update(S) <- S - n_SI
update(I) <- I + n_SI - n_IR
update(R) <- R + n_IR
update(incidence) <- incidence + n_SI
beta <- interpolate(beta_time, beta_value, "constant")
beta_time <- parameter()
beta_value <- parameter()
dim(beta_time, beta_value) <- parameter(rank = 1)
p_SI <- 1 - exp(-beta * I / N * dt)
p_IR <- 1 - exp(-gamma * dt)
n_SI <- Binomial(S, p_SI)
n_IR <- Binomial(I, p_IR)
initial(S) <- N - I0
initial(I) <- I0
initial(R) <- 0
initial(incidence, zero_every = 1) <- 0
N <- parameter(1000)
I0 <- parameter(10)
gamma <- parameter(0.1)
})sir_age_vax <- odin({
# Equations for transitions between compartments by age group
update(S[, ]) <- new_S[i, j]
update(I[, ]) <- I[i, j] + n_SI[i, j] - n_IR[i, j]
update(R[, ]) <- R[i, j] + n_IR[i, j]
update(incidence) <- incidence + sum(n_SI)
# Individual probabilities of transition:
p_SI[, ] <- 1 - exp(-rel_susceptibility[j] * lambda[i] * dt) # S to I
p_IR <- 1 - exp(-gamma * dt) # I to R
p_vax[, ] <- 1 - exp(-eta[i, j] * dt)
# Force of infection
m <- parameter() # age-structured contact matrix
s_ij[, ] <- m[i, j] * sum(I[j, ])
lambda[] <- beta * sum(s_ij[i, ])
# Draws from binomial distributions for numbers changing between
# compartments:
n_SI[, ] <- Binomial(S[i, j], p_SI[i, j])
n_IR[, ] <- Binomial(I[i, j], p_IR)
# Nested binomial draw for vaccination in S
# Assume you cannot move vaccine class and get infected in same step
n_S_vax[, ] <- Binomial(S[i, j] - n_SI[i, j], p_vax[i, j])
new_S[, 1] <- S[i, j] - n_SI[i, j] - n_S_vax[i, j] + n_S_vax[i, n_vax]
new_S[, 2:n_vax] <- S[i, j] - n_SI[i, j] - n_S_vax[i, j] + n_S_vax[i, j - 1]
initial(S[, ]) <- S0[i, j]
initial(I[, ]) <- I0[i, j]
initial(R[, ]) <- 0
initial(incidence, zero_every = 1) <- 0
# User defined parameters - default in parentheses:
S0 <- parameter()
I0 <- parameter()
beta <- parameter(0.0165)
gamma <- parameter(0.1)
eta <- parameter()
rel_susceptibility <- parameter()
# Dimensions of arrays
n_age <- parameter()
n_vax <- parameter()
dim(S, S0, n_SI, p_SI) <- c(n_age, n_vax)
dim(I, I0, n_IR) <- c(n_age, n_vax)
dim(R) <- c(n_age, n_vax)
dim(m, s_ij) <- c(n_age, n_age)
dim(lambda) <- n_age
dim(eta) <- c(n_age, n_vax)
dim(rel_susceptibility) <- n_vax
dim(p_vax, n_S_vax, new_S) <- c(n_age, n_vax)
})dim equationi, j etc on LHS - indexing on the LHS is impliciti, j, k, l, i5, i6, i7, i8sum, prod, min, max - can be applied over entire array or slicesodin models with montyWe return to our data on the daily incidence of cases
Letβs fit these data to our model
sir <- odin({
update(S) <- S - n_SI
update(I) <- I + n_SI - n_IR
update(R) <- R + n_IR
update(incidence) <- incidence + n_SI
p_SI <- 1 - exp(-beta * I / N * dt)
p_IR <- 1 - exp(-gamma * dt)
n_SI <- Binomial(S, p_SI)
n_IR <- Binomial(I, p_IR)
initial(S) <- N - I0
initial(I) <- I0
initial(R) <- 0
initial(incidence, zero_every = 1) <- 0
N <- parameter(1000)
I0 <- parameter(10)
beta <- parameter(0.2)
gamma <- parameter(0.1)
})We will link cases in the data to incidence in the model, and we will treat beta and gamma as unknown parameters to be estimated
sir <- odin({
update(S) <- S - n_SI
update(I) <- I + n_SI - n_IR
update(R) <- R + n_IR
update(incidence) <- incidence + n_SI
initial(S) <- N - I0
initial(I) <- I0
initial(R) <- 0
initial(incidence, zero_every = 1) <- 0
p_SI <- 1 - exp(-beta * I / N * dt)
p_IR <- 1 - exp(-gamma * dt)
n_SI <- Binomial(S, p_SI)
n_IR <- Binomial(I, p_IR)
N <- parameter(1000)
I0 <- parameter(10)
beta <- parameter(0.2)
gamma <- parameter(0.1)
cases <- data()
cases ~ Poisson(incidence)
})dust_likelihood_run(filter, list(beta = 0.4, gamma = 0.2),
save_trajectories = TRUE)
#> [1] -87.03677
y <- dust_likelihood_last_trajectories(filter)
y <- dust_unpack_state(filter, y)
matplot(data$time, t(y$incidence), type = "l", col = "#00000044", lty = 1,
xlab = "Time", ylab = "Incidence")
points(data, pch = 19, col = "red")So we have a marginal likelihood estimator from our particle filter
How do we sample from beta and gamma?
We need:
beta and gamma, pars
parspars (and back again)Our solution, βpackersβ
packer <- monty_packer(c("beta", "gamma"), fixed = list(I0 = 10))
packer
#>
#> ββ <monty_packer> ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#> βΉ Packing 2 parameters: 'beta' and 'gamma'
#> βΉ Use '$pack()' to convert from a list to a vector
#> βΉ Use '$unpack()' to convert from a vector to a list
#> βΉ See `?monty_packer()` for more informationWe can transform from \(\theta\) to a named list:
Another DSL, similar to odinβs:
This is a βmonty modelβ
prior
#>
#> ββ <monty_model> βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#> βΉ Model has 2 parameters: 'beta' and 'gamma'
#> βΉ This model:
#> β’ can compute gradients
#> β’ can be directly sampled from
#> β’ accepts multiple parameters
#> βΉ See `?monty_model()` for more information
monty_model_density(prior, c(0.2, 0.1))
#> [1] 1.163787filter
#>
#> ββ <dust_likelihood (odin_system)> βββββββββββββββββββββββββββββββββββββββββββββ
#> βΉ 4 state x 200 particles
#> βΉ The likelihood is stochastic
#> βΉ This system runs in discrete time with dt = 0.25
#> βΉ Use coef() (`?stats::coef()`) to get more information on parametersCombine a filter and a packer
packer <- monty_packer(c("beta", "gamma"))
likelihood <- dust_likelihood_monty(filter, packer, save_trajectories = TRUE)
likelihood
#>
#> ββ <monty_model> βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#> βΉ Model has 2 parameters: 'beta' and 'gamma'
#> βΉ This model:
#> β’ is stochastic
#> β’ has an observer
#> βΉ See `?monty_model()` for more informationHere we specify save_trajectories = TRUE to output the state trajectories at even collected sample.
Combine a likelihood and a prior to make a posterior
\[ \underbrace{\Pr(\theta | \mathrm{data})}_{\mathrm{posterior}} \propto \underbrace{\Pr(\mathrm{data} | \theta)}_\mathrm{likelihood} \times \underbrace{P(\theta)}_{\mathrm{prior}} \]
posterior <- likelihood + prior
posterior
#>
#> ββ <monty_model> βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#> βΉ Model has 2 parameters: 'beta' and 'gamma'
#> βΉ This model:
#> β’ can be directly sampled from
#> β’ is stochastic
#> β’ has an observer
#> βΉ See `?monty_model()` for more information(remember that addition is multiplication on a log scale)
A diagonal variance-covariance matrix (uncorrelated parameters)
Use this to create a βrandom walkβ sampler:
sampler <- monty_sampler_random_walk(vcv)
sampler
#>
#> ββ <monty_sampler: Random walk (monty_random_walk)> ββββββββββββββββββββββββββββ
#> βΉ Use `?monty_sample()` to use this sampler
#> βΉ See `?monty_random_walk()` for more informationsamples <- monty_sample(posterior, sampler, 1000, n_chains = 3)
samples
#>
#> ββ <monty_samples: 2 parameters x 1000 samples x 3 chains> βββββββββββββββββββββ
#> βΉ Parameters: 'beta' and 'gamma'
#> βΉ Conversion to other types is possible:
#> β ! posterior::as_draws_array() [package installed, but not loaded]
#> β ! posterior::as_draws_df() [package installed, but not loaded]
#> β β coda::as.mcmc.list() [package missing]
#> βΉ These samples have associated observations
#> βΉ See `?monty_sample()` and `vignette("samples")` for more informationDiagnostics can be used from the posterior package
## Note: as_draws_df converts samples$pars, and drops anything else in samples
samples_df <- posterior::as_draws_df(samples)
posterior::summarise_draws(samples_df)
#> # A tibble: 2 Γ 10
#> variable mean median sd mad q5 q95 rhat ess_bulk ess_tail
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 beta 0.845 0.832 0.130 0.117 0.670 1.07 1.05 57.2 45.9
#> 2 gamma 0.537 0.511 0.108 0.0882 0.401 0.704 1.07 79.7 66.2You can use the posterior package in conjunction with bayesplot (and then also ggplot2)
samples_df <- posterior::as_draws_df(samples)
posterior::summarise_draws(samples_df)
#> # A tibble: 2 Γ 10
#> variable mean median sd mad q5 q95 rhat ess_bulk ess_tail
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 beta 0.853 0.838 0.123 0.117 0.675 1.08 1.01 495. 741.
#> 2 gamma 0.538 0.526 0.0980 0.0914 0.400 0.713 1.00 462. 617.Trajectories are 4-dimensional
These can get very large quickly - there are two main ways to help reduce this:
odin2dust2montydust2monty DSLmonty_sampleThe key difference is to use dust_unfilter_create
Note as this is deterministic it produces the same likelihood every time
pars_stochastic <- array(samples$pars, c(2, 500))
pars_deterministic <- array(samples_det$pars, c(2, 500))
plot(pars_stochastic[1, ], pars_stochastic[2, ], ylab = "gamma", xlab = "beta",
pch = 19, col = "blue")
points(pars_deterministic[1, ], pars_deterministic[2, ], pch = 19, col = "red")
legend("bottomright", c("stochastic fit", "deterministic fit"), pch = c(19, 19),
col = c("blue", "red"))Example implementation of both of these can be found here
Two places to parallelise
e.g., 4 threads per filter x 2 workers = 8 total cores in use
These are both straightforward to setup, a demonstration can be found here
monty: parallel temperingMotivating example: likelihood is a mixture of normals model
prior is a normal distribution with a wider variance
monty: parallel temperingposterior is the product of the two (the sum on a log-scale)
monty: parallel temperingTry a random walk Metropolis-Hastings sampler for this model
monty: parallel temperingOnly one mode has been explored
monty: parallel temperingMultiple chains does not solve the issue
monty: parallel temperingParallel tempering helps solve such problems
monty: parallel temperingUse our RW sampler within the parallel tempering sampler
monty: parallel temperingParallel tempering helps us explore both modes
monty samplersmonty will handle bookkeeping and parallelismmonty support for augmented dataodin & monty book: https://mrc-ide.github.io/odin-monty/odin to odin2: https://mrc-ide.github.io/odin2/articles/migrating.htmldust to dust2: https://mrc-ide.github.io/dust2/articles/migrating.htmlmcstate to monty: https://mrc-ide.github.io/monty/articles/migration.htmlodin-monty repo: https://github.com/mrc-ide/odin-monty/discussions