Getting started
This page runs one complete analysis: how often do isolation and contact tracing contain an outbreak of a pathogen with R = 2.5 and strong superspreading? Along the way it defines a model, simulates outbreaks, and turns the results into line lists and summary tables. Times throughout are in days. If the Julia syntax is unfamiliar, see Julia for R users.
Defining a model
A branching process needs at least the distribution of the number of secondary cases each case infects (the offspring distribution). Interventions act in time, and to use them you also need a generation time distribution: the time from the infection of an infector to the infection of the person it infects.
using EpiBranch
using Distributions
using StableRNGs
model = BranchingProcess(
NegBin(2.5, 0.16), # R = 2.5, dispersion k = 0.16
LogNormal(1.6, 0.5) # generation time, in days
)BranchingProcess(offspring=Distributions.NegativeBinomial{Float64}, generation_time=Distributions.LogNormal{Float64}, population_size=unlimited)NegBin is the negative binomial written the epidemiological way, with mean R and dispersion k. Smaller k means more variation between cases: with k = 0.16 most cases infect nobody and a few infect many. The generation time is measured between infections; the serial interval, between symptom onsets, is a different quantity.
LogNormal(μ, σ), from the Distributions package, takes the mean and standard deviation of the log of the delay: LogNormal(1.6, 0.5) has a median of about 5 days and a mean of about 5.6 days. mean and std give the values on the natural scale:
gt = LogNormal(1.6, 0.5)
(mean = mean(gt), sd = std(gt))(mean = 5.612521029693157, sd = 2.9911389221029934)To start from a mean m and standard deviation s in days, use σ = sqrt(log(1 + s^2 / m^2)) and μ = log(m) - σ^2 / 2.
This is already a complete model. You can add three more parts to it with a ModelSpec, as the sections below do: characteristics of the people (such as their incubation period), interventions (such as isolation), and how cases are observed or reported. Only the transmission part is required.
Simulating one outbreak
rng = StableRNG(3) # a fixed random seed, like set.seed() in R
outbreak = simulate(model;
max_cases = 500,
rng = rng,
)
println("Cases: $(outbreak.cumulative_cases), Extinct: $(outbreak.extinct)")Cases: 500, Extinct: falseThe simulation starts from one index case and stops when transmission dies out (Extinct: true) or the outbreak reaches max_cases. An outbreak stopped at the cap is recorded as not extinct. A branching process stops at exactly max_cases cases, and the last cases have not yet made all their contacts. Other limits are a number of generations (max_generations, 100 by default) and a time (max_time, in days).
The result holds one record per person (every case, and every potential secondary case that was not infected), plus summary information such as the total number of cases and whether the outbreak died out. A run stopped at the cap also keeps contacts whose infection was still undecided when it stopped, if an intervention had already reached them. Their infection time is NaN.
Adding symptom onset
Isolation on symptom onset needs to know when people develop symptoms. Give the cases an incubation period (infection to onset) with clinical_presentation, passed to the model as attributes:
spec = ModelSpec(BranchingProcess(NegBin(2.5, 0.16), LogNormal(1.6, 0.5));
attributes = clinical_presentation(incubation_period = LogNormal(1.5, 0.5)),
)
rng = StableRNG(42)
outbreak = simulate(spec; max_cases = 500, rng = rng)
# The index case: infected at day 0, with an onset some days later
ind = outbreak.individuals[1]
println("Infection: day $(round(ind.infection_time, digits=1)), Onset: day $(round(onset_time(ind), digits=1))")Infection: day 0.0, Onset: day 5.6Times are in days from the infection of the first index case.
Adding interventions
Interventions are added to the model in the same way, and simulate then applies them:
# Symptomatic cases isolate on average 2 days after onset, for 7 days
iso = Isolation(onset_to_isolation_delay = Exponential(2.0), duration = 7.0)
# Each contact of an isolated case is traced with probability 0.5, on average
# 1.5 days after the case isolates, and quarantined for 7 days
ct = ContactTracing(
probability = 0.5, isolation_to_trace_delay = Exponential(1.5),
action = Quarantine(duration = 7.0)
)
spec = ModelSpec(BranchingProcess(NegBin(2.5, 0.16), LogNormal(1.6, 0.5));
attributes = clinical_presentation(incubation_period = LogNormal(1.5, 0.5)),
interventions = [iso, ct],
)
rng = StableRNG(18)
outbreak = simulate(spec; max_cases = 500, rng = rng)
println("Cases: $(outbreak.cumulative_cases), Extinct: $(outbreak.extinct)")
# Leave out contacts whose infection was undecided when the run stopped
decided = filter(i -> isfinite(i.infection_time), outbreak.individuals)
println("Isolated or quarantined: $(count(is_isolated, decided))")
println("Traced: $(count(is_traced, decided))")Cases: 500, Extinct: false
Isolated or quarantined: 650
Traced: 388Exponential(θ) has mean θ, here in days. The action says what happens to a traced contact: here they are quarantined, which stops them transmitting from the time they are traced, even before they have symptoms. duration sets how long isolation and quarantine last. When it ends the person is released, and one who is still infectious can transmit again. Use duration = Inf for isolation or quarantine that is never lifted. count(is_isolated, decided) counts the people who were isolated or quarantined at some point, including quarantined contacts who were never infected. decided leaves out the contacts whose infection was still undecided when the run stopped at the cap, which have no infection time; counted over outbreak.individuals, they would add 371 to each line. Extinct shows whether the measures ended transmission in this outbreak before it reached the cap.
Estimating the containment probability
One outbreak says little; the question is how often the measures work. Simulate 500 outbreaks:
rng = StableRNG(42)
results = simulate(spec, 500; max_cases = 5000, rng = rng)
println("Containment probability: $(round(containment_probability(results), digits=3))")Containment probability: 0.92The containment probability is the proportion of simulated outbreaks in which transmission stopped before reaching 5000 cases (or 100 generations). With a pathogen this overdispersed, many outbreaks die out by chance even without control. Compare it with the extinction probability without interventions, calculated under Exact results below.
Potential and actual secondary cases
The number drawn from the offspring distribution is the number of potential secondary cases: the people a case would infect if nothing intervened. Without interventions all of them are infected, and the mean of NegBin(2.5, 0.16) is R. Isolation and quarantine prevent some of these infections. Fewer people are then infected than were drawn, and the reproduction number realised under control is below 2.5. The people whose infection was prevented stay in the results as contacts who were not infected. They keep the time at which they would have been infected, which tells them apart from the contacts whose infection was still undecided when the run reached the cap:
n_total = length(outbreak.individuals)
n_infected = count(is_infected, outbreak.individuals)
n_prevented = count(i -> !is_infected(i) && isfinite(i.infection_time), outbreak.individuals)
println("People in the simulation: $n_total")
println("Infected (cases, including the index case): $n_infected")
println("Potential secondary cases not infected: $n_prevented")People in the simulation: 1364
Infected (cases, including the index case): 500
Potential secondary cases not infected: 307The last line is the number of infections the interventions prevented in this outbreak. The remaining 752 people are contacts whose infection was still undecided when the run stopped, kept because an intervention had already reached them. The simulation draws no contacts for anyone who is not infected. See Isolation and contact tracing for more.
Tables of results
The simulation can be turned into data frames, much as in R.
using DataFrames, Dates
# Line list: one row per case
ll = linelist(outbreak; reference_date = Date(2024, 1, 1))
println("Line list: $(nrow(ll)) rows, $(ncol(ll)) columns")
first(ll, 3) # like head(ll, 3) in R| Row | id | parent_id | generation | chain_id | date_infection | date_isolation | date_isolation_release | date_onset | date_trace | asymptomatic | incubation_period | index | isolated | quarantined | test_positive | traced | traced_by |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Int64 | Int64 | Int64 | Int64 | Date | Date? | Date? | Date? | Date? | Bool | Float64 | Bool | Bool | Bool | Bool | Bool | Int64? | |
| 1 | 1 | 0 | 0 | 1 | 2024-01-01 | 2024-01-05 | 2024-01-12 | 2024-01-03 | missing | false | 2.56649 | true | true | false | true | false | missing |
| 2 | 3 | 1 | 1 | 1 | 2024-01-04 | 2024-01-14 | 2024-01-21 | 2024-01-08 | missing | false | 4.23336 | false | true | false | true | false | missing |
| 3 | 4 | 1 | 1 | 1 | 2024-01-04 | 2024-01-10 | 2024-01-17 | 2024-01-10 | missing | false | 5.7953 | false | true | false | true | false | missing |
Simulated times are in days from the start of the outbreak; reference_date sets the calendar date of day 0 and gives the line list dates, as in surveillance data. When isolation or quarantine has a finite duration, as here, the line list also gives the release date in date_isolation_release.
# Contacts table: one row per infector and potential secondary case,
# with whether that person was infected
ct_df = contacts(outbreak; reference_date = Date(2024, 1, 1))
println("Contacts: $(nrow(ct_df)) ($(count(ct_df.infected)) infected)")Contacts: 806 (499 infected)A transmission chain is all the cases that descend from one index case. Its size is the number of cases in it, including the index case, and its length is the number of generations after the index case:
cs = chain_statistics(outbreak)
cs| Row | chain_id | size | length |
|---|---|---|---|
| Int64 | Int64 | Int64 | |
| 1 | 1 | 500 | 9 |
generation_R gives, for each generation, the average number of people its cases went on to infect: the cases in the next generation divided by the cases in this one. It is a rough within-simulation measure of how transmission changes as the measures take effect. It is not the time-varying reproduction number Rt estimated from incidence data.
A run stopped by max_cases ends part-way through its last generations. Their cases have not yet infected everyone they would, and the ratio falls towards 0 there whatever the measures do. The example below runs the same model with the same seed, bounded by six generations (max_generations = 6, with max_cases = nothing to remove the case cap). Every case before the sixth generation then has all its contacts decided, and each ratio is complete:
bounded = simulate(spec; max_cases = nothing, max_generations = 6, rng = StableRNG(18))
r_df = generation_R(bounded)
r_df| Row | generation | offspring_ratio |
|---|---|---|
| Int64 | Float64 | |
| 1 | 0 | 14.0 |
| 2 | 1 | 3.14286 |
| 3 | 2 | 1.52273 |
| 4 | 3 | 1.8209 |
| 5 | 4 | 1.0 |
| 6 | 5 | 1.60656 |
This outbreak escaped control, growing to 771 cases in six generations, and its ratio stays above 1. In an outbreak the measures contain, the ratio falls below 1 in the later generations. With few cases per generation and strong superspreading it also jumps about from one generation to the next.
Exact results
Some quantities can be calculated exactly from the offspring distribution, without simulation. The functions below are given model, the process on its own. Passing spec stops with an error, because it includes Isolation, whose effect on R depends on how its timing overlaps the generation time, and there is no closed form for that:
println("P(extinction): $(round(extinction_probability(model), digits=3))")
println("P(epidemic): $(round(epidemic_probability(model), digits=3))")
println("Top 20% cause $(round(proportion_transmission(model; prop_cases=0.2) * 100, digits=1))% of transmission")P(extinction): 0.795
P(epidemic): 0.205
Top 20% cause 88.3% of transmissionThese describe the outbreak without control
extinction_probability, epidemic_probability and proportion_transmission use only the offspring distribution. The extinction probability printed here is the chance that an outbreak dies out with no control at all. Compare it with the containment probability simulated above to see what the interventions add. For a closed-form containment probability under simple control, see probability_contain in Analytical functions.
The first line is the probability that a single introduction dies out by chance. The last line is the share of all transmission caused by the 20% of cases that transmit most; with k = 0.16 it is a large majority.
Outbreaks of a given size
To study outbreaks of a particular size, for example to compare with an observed cluster, keep only simulated outbreaks whose final size is in range:
rng = StableRNG(42)
outbreak = simulate(spec;
condition = 50:100, # every whole number from 50 to 100
max_cases = 200,
rng = rng,
)
println("Outbreak size: $(outbreak.cumulative_cases) (target: 50-100)")Outbreak size: 56 (target: 50-100)simulate reruns the outbreak until one ends with between 50 and 100 cases, discarding the rest, and returns that one.
Rare sizes take many attempts
If outbreaks of the requested size are rare, this needs many simulations. It gives up with an error after max_attempts (10,000 by default). Keep max_cases above the top of the range: an outbreak stopped at the cap does not have a final size.
Next steps
Interventions:
Isolation and contact tracing: isolating cases and tracing and quarantining their contacts
Vaccination: ring, mass and group vaccination, and protecting contacts already exposed
Clinical transitions: clinical progression from symptom onset to reporting, admission, and recovery or death
Transmission models:
Multi-type models: age-structured and heterogeneous transmission
Network models: transmission over a fixed contact network
Household models: transmission within and between households
Homogeneous models: a closed, well-mixed population
Covariates and time-varying transmission: transmission that depends on who is in contact, the date, or what has happened so far in the outbreak (for example an intervention starting)
Line lists and contacts: generating epidemiological data
Analysis:
Chain statistics: chain size and length, and their likelihoods
Analytical functions: exact results without simulation
Inference: estimating parameters from outbreak data
Extending EpiBranch:
Extending EpiBranch: which tool fits what you want to model, and changes that need no new code structure
Writing an intervention: control measures and clinical events of your own
New transmission structures: contact structures, routes and transmission models of your own
Extension reference: the details an extension has to respect