Skip to main content

camdl

🧠0% 🤖100%

This page is for people who write models in camdl: compartmental models in a checked language, with typed rates and durations, observations and their likelihoods in the model file, and fits by particle filtering, IF2, PMMH, and NUTS from the command line. It shows a camdl model next to the same model in Starsim, how their results agree, how long each takes, and what works and what does not yet, including what fails.

The same model in both

camdl’s SIR: a closed epidemic, observed through weekly reported cases with a negative binomial likelihood:

#' A closed SIR epidemic, observed through weekly reported cases.

time_unit = 'days

compartments { S, I, R }

parameters {
  #' transmission rate
  beta  : rate         in [0.05, 1.0]  ~ log_normal(mu = -1.0, sigma = 0.5)
  #' recovery rate
  gamma : rate         in [0.01, 0.5]  ~ log_normal(mu = -2.0, sigma = 0.5)
  #' reporting fraction
  rho   : probability
  #' dispersion of the reported counts
  k     : positive
}

let N = S + I + R

transitions {
  infection : S --> I  @ beta * S * I / N
  recovery  : I --> R  @ gamma * I
}

observations {
  cases {
    columns       { time : time, cases : count }
    covers        = closing_at(time, 7 'days)
    projected     = incidence(infection)
    emit_schedule = every 7 'days
    cases         ~ neg_binomial(mean = rho * projected, r = k)
  }
}

init {
  S = 9990
  I = 10
}

simulate {
  from = 0 'days
  to   = 12 'weeks
}

In Starsim, parameters carry their bounds and priors, a bare state in a rate is already a share (so beta * I is camdl’s beta * S * I / N, per susceptible), and the observation is an outcome with its likelihood, compared with weekly data as its total over each week:

import starsim as ss

beta  = ss.par(ss.perday(0.4),  bounds=[0.05, 1.0], prior=ss.lognormal(log_mean=-1.0, log_std=0.5), doc='transmission rate')
gamma = ss.par(ss.perday(0.15), bounds=[0.01, 0.5], prior=ss.lognormal(log_mean=-2.0, log_std=0.5), doc='recovery rate')
rho   = ss.par(0.6, doc='reporting fraction')
k     = ss.par(10,  doc='dispersion of the reported counts')

S, I, R = ss.states(3)
infection = S >> I @ beta * I
recovery  = I >> R @ gamma
cases     = ss.outcome(rho * infection, likelihood=ss.nbinom(n=k)) # Compared with weekly data as its total over each week

sir = ss.Disease(infection, recovery, cases, init_n=dict(I=10))
sim = ss.Sim(sir, total_pop=10_000, unit='day', stop=ss.weeks(12), verbose=0)
sim.run()
sim.plot()

With data attached (ss.Sim(..., data=...), a table with columns time and cases), sim.fit() fits beta and gamma (a best fit); sim.fit(method='nuts') gives the posterior, or method='pmcmc' with solver='binomial'.

How the results agree

Model Agreement
The SIR (above) Agree: as an ODE, compartments and weekly cases within 0.02%; as a chain binomial, the mean of 200 runs within about 1% for I and 1.6% for weekly cases. Fitted by NUTS, the same posterior as camdl’s
An SEIR by 3 ages × 2 patches, with a contact matrix, aging, movement, and vaccination on day 20 Agree: as an ODE, the infectious in every cell within 0.24%, with and without vaccination; stochastic peaks agree in size, and the south’s epidemic rises about a day sooner at a step of a day (less at a quarter-day step)
camdl’s 5-patch polio model with gravity importation and three SIA rounds per patch Agree: total I peaks at 130,643 on day 145, as in camdl’s ODE and an independent SciPy ODE; total infections over three years are 950,610 without SIAs and 863,920 with them, the same as camdl’s
Fitting the SIR to 12 weeks of cases by particle MCMC Agree: beta 0.427 (95% from 0.348 to 0.528) against camdl PMMH’s 0.430 (0.348 to 0.523), and gamma 0.152 (0.061 to 0.333) against 0.159 (0.062 to 0.303)
The same, by maximum likelihood (IF2, sim.fit(method='if2')) Agree: beta 0.474 and gamma 0.235 against camdl IF2’s 0.480 and 0.230; four chains from different starts end within 0.1 of the same log-likelihood

Timings

Lines are lines of model code (not counting blank lines, comments, imports, or plotting, and counting a line over 100 characters twice), against the lines of the .camdl file; times are for one run, the first in a new process and a rerun of the same model. Both were measured as described on the comparison page. camdl’s times were recorded on the same machine.

Model Lines: camdl Lines: Starsim One run: camdl One run: Starsim
The SIR, as an ODE 30 13 first 0.013 s, rerun 0.0056 s as a command (camdl simulate --backend ode) first 0.0094 s, rerun 0.00029 s Pass
The SEIR by age and patch, as an ODE 47 16 first 0.023 s, rerun 0.013 s as a command first 0.014 s, rerun 0.00052 s Pass
The SIR fitted by particle MCMC (4 chains) 21 s (PMMH, 200 particles) 79 s (50 particles, chosen in the warm-up)
The SIR fitted by IF2 (4 chains of 50 passes, 1,000 particles) 11.9 s 13 s

The last two rows were measured on 18 October 2026, on a machine shared with other jobs: Starsim’s particle filter took 12 ms per likelihood with 200 particles (on the machine’s threads; 36 ms on one) against camdl’s 10.4 ms.

These times are indicative only. By the rule on the comparison page (about the same within a factor of 2, or within 0.1 s for a first run or 1 ms for a rerun), the first run of either model is about the same in the two, and Starsim is faster to rerun both; the particle filter and IF2 are about the same, and particle MCMC took longer in Starsim, with a different number of particles. camdl’s times are for the command, so they include starting the program (about 10 ms), while Starsim’s are inside Python, with Starsim already imported (which takes about 2 s): which is fair depends on the work, since a fit or a sweep from Python pays the import once.

What works and what does not yet

These are from Starsim’s switching demo for camdl users (tests/switching/test_camdl.py in this repo), which checks each task against camdl’s numbers, and from the review it was built from. “Fails” means the demo’s check does not pass today.

Task Today
The polio model with places as patch= and an explicit contact matrix between them Works: agreement as above, below
SIAs as one-off moves between states (camdl’s transfer(S -> V)) Works: ss.vaccinate(S >> V, when=[...], coverage=...), below. A one-off move is run from Python each step, so the run is taken a step at a time rather than in one call to compiled code
Unit slips that camdl’s checker catches Works for two of three: a duration used as a rate, and a rate multiplied by a duration (a number with no unit), raise a TypeError before the run. A count where a share was meant (I.n for I) is density-dependent transmission, a model of its own, so it is a note (when the rate is far too fast for one); if it makes the run blow up, the run stops with an error, not with NaN
A posterior by particle MCMC with nothing written but data= and sim.fit(method='pmcmc') Works: the same posterior as camdl’s PMMH (above)
The speed of the particle filter Works: 12 ms per likelihood against camdl’s 10.4 ms (above), with the particles shared between threads
Maximum likelihood by IF2 Works: sim.fit(method='if2') gives camdl’s estimates (above), for parameters of the model’s rates; a parameter of how the data are observed (a reporting rate) cannot be fitted by IF2 yet
Profile likelihoods Works for an ODE: ss.fitting.profile(sim, 'beta') refits the other parameters at each value of beta and gives its confidence interval; a stochastic model raises an error, since its noisy likelihoods would need a smoothed profile, which is not yet supported
Import a .camdl file Works (see below), with every stratification expanded into states, so a stratified model comes in as one state per combination of levels
Interventions in a scenario (SIAs) in an imported model Not yet: they are listed as not translated
The same model as agents Works: solver='abm' with a network; camdl is compartmental only

The polio model by hand, with places as patch= and importation as a contact matrix between them, without and with SIAs:

import numpy as np

patches = ['north', 'south', 'east', 'west', 'center']
N0 = np.array([150_000, 200_000, 120_000, 100_000, 500_000])
R0 = np.array([2.5, 2.2, 1.9, 2.0, 3.0])
W = np.array([[0.00, 0.10, 0.05, 0.05, 0.15], [0.10, 0.00, 0.10, 0.05, 0.10], [0.05, 0.10, 0.00, 0.10, 0.10],
              [0.05, 0.05, 0.10, 0.00, 0.15], [0.15, 0.10, 0.10, 0.15, 0.00]]) # Gravity importation between patches
sia_days = dict(north=[180, 545, 910], south=[190, 555, 920], east=[200, 565, 930], west=[200, 565, 930], center=[185, 550, 915])

def make_polio(with_sia=False):
    gamma, kappa = 0.1, 0.01
    S, E, I, R, V = ss.states(5)
    seir = ss.Disease(
        infection   = S >> E @ ss.perday(1) * I,
        progression = E >> I @ ss.perday(0.2),
        recovery    = I >> R @ ss.perday(gamma),
        waning      = V >> S @ ss.perday(0.003),
        init_n      = dict(I=ss.by(patch=[5, 0, 0, 0, 0])),
        name        = 'seir',
    )
    seir.stratify(patch=ss.levels(dict(zip(patches, N0/N0.sum())), kind='space'))
    sias = [ss.vaccinate(S >> V, when=days, coverage=ss.by(patch=[0.8*(q == p) for q in patches]), name=f'sia_{p}') for p, days in sia_days.items()]
    return ss.Sim(seir, networks=ss.contacts(patch=np.diag(R0*gamma) + kappa*W), interventions=sias if with_sia else None,
                  total_pop=N0.sum(), unit='day', stop=3*365, solver='ode', verbose=0)

base = make_polio().run().results.seir
sia = make_polio(with_sia=True).run().results.seir
print('Peak:', round(base.I.max()), 'on day', base.I.values.argmax(), '(camdl: 130643 on day 145)')
print('Infections without SIAs:', round(base.infection.sum()), '(camdl: 950610)')
print('Infections with SIAs:', round(sia.infection.sum()), '(camdl: 863920)')
Peak: 130643 on day 145 (camdl: 130643 on day 145)
Infections without SIAs: 950610 (camdl: 950610)
Infections with SIAs: 863920 (camdl: 863920)

Bringing your model in

ss.import_from('model.camdl', format='camdl') reads a camdl model: a .camdl file is compiled with camdlc (from the PATH, or CAMDLC) into camdl’s JSON intermediate representation, which is what is read; the IR itself can be read too. Each transition becomes a transition, its rate classified by Starsim’s import rules (beta * S * I / N is exposure to I), parameters keep their bounds and priors, and each observation of a flow becomes an outcome with its likelihood. scenario= chooses a scenario’s parameter values. An event that adds people at fixed times becomes an inflow from outside the population at those times. Extra noise in a rate (overdispersed(rate, var)) becomes noise= on the transition, which is σ where camdl’s argument is σ², so a parameter var becomes a fitted parameter sqrt_var, with the square roots of its value and bounds (for He et al., sqrt_sigma_se). What is not translated (other scheduled effects and interventions, ODE compartments, forcing functions of time, a balance, and a draw that camdl makes deterministic under a stochastic solver) is listed (sim.imported.incomplete), and the warning ends with the command that finishes the job with an AI coding agent (starsim import model.camdl camdl --ai); sim.imported.prompt is the same request, for an AI assistant with Starsim’s convert-model skill.

See Import/export for importing in general: checking an import, keeping it as code, and finishing one that is incomplete.

Further reading