EpiModel
| Language | R (with C/C++ via Rcpp for network resimulation) |
| Paradigms | Deterministic compartmental (ODE); stochastic individual-contact (random mixing); stochastic dynamic-network (TERGM) |
| Specification style | Function-call API: param.*() / init.*() / control.*(), plus user-supplied module functions |
| Version reviewed | 2.6.2 (2026-07-29) |
| License | GPL-3 |
| Code | https://github.com/EpiModel/EpiModel |
| Docs | https://www.epimodel.org/, https://epimodel.github.io/EpiModel/ |
| Paper | Jenness, Goodreau & Morris (2018), JSS 84(8), 10.18637/jss.v084.i08 |
| Ecosystem | statnet (ergm, network, networkDynamic, tergm, networkLite) below it; EpiModelHIV, EpiModelCOVID, EpiModelHPC, EpiModel Gallery above it |
| Moving to Starsim | Conversion guide |
What it’s for
EpiModel simulates infectious disease dynamics over statistically estimated contact networks. What sets it apart is the network model (the built-in diseases are SI, SIR, and SIS): partnerships are generated by an exponential-family random graph model (ERGM) fitted to target statistics such as mean degree, concurrency, partnership duration, and assortative mixing. Nothing else in the landscape does this. Elsewhere, contact structure is assumed (random mixing, contact matrices) or read from data; in EpiModel it is fitted, with its own diagnostics. This project owes a lot to that work, and to EpiModel as a teaching tool.
The deterministic and random-mixing model classes are mainly a ladder for teaching and comparison: the same conceptual model can be run as an ODE, a stochastic random-mixing model, and a network model, so that the contribution of each assumption is visible.
How a model is specified
Three inputs, always, for all three model classes:
param.dcm()/param.icm()/param.net(): epidemic parametersinit.dcm()/init.icm()/init.net(): initial conditionscontrol.dcm()/control.icm()/control.net(): model type, number of steps, number of simulations, and which modules to run
and then dcm(param, init, control), icm(...), or netsim(est, param, init, control).
Each of these takes named arguments and any others through .... So parameters are open: param.net(inf.prob = 0.4, act.rate = 2, my.new.param = 0.7) is legal, and any module can read my.new.param with get_param(dat, "my.new.param"). Parameters are not declared, so a misspelled name is not an error until something looks for it.
Network models add two steps in front of the epidemic model:
netest(nw, formation, target.stats, coef.diss): fit the (T)ERGM.formationis a one-sided R formula of ERGM terms (~edges + concurrent + degrange(from = 4)),target.statsare the values those terms should take, andcoef.dissencodes partnership dissolution, usually as a mean duration.netdx(est, nsims, nsteps): simulate from the fitted model and check that formation statistics and durations recover their targets. The documentation treats this step as mandatory, so the contact structure is checked before any disease runs on it. This is excellent practice.
Extension: modules
Beyond the built-in types, a model is extended by writing module functions and passing them to control.net(). A module has a very simple form:
progress_module <- function(dat, at) {
status <- get_attr(dat, "status")
ids.EtoI <- which(status == "e" & runif(length(status)) < 0.1)
status[ids.EtoI] <- "i"
dat <- set_attr(dat, "status", status)
dat <- set_epi(dat, "ei.flow", at, length(ids.EtoI))
return(dat)
}A module is function(dat, at) -> dat: it takes the whole simulation state and the time step, changes the state, and returns it. control.net() has named slots (infection.FUN, recovery.FUN, departures.FUN, arrivals.FUN, nwupdate.FUN, prevalence.FUN, and so on), and any other .FUN argument becomes a new module. Modules run in slot order, which module.order can replace.
dat is reached through a small set of functions: get_attr/set_attr (per-agent attributes), get_param/set_param, get_epi/set_epi (output time series), get_control, get_current_timestep, add_attr, and get_posit_ids. These are the closest thing EpiModel has to a declared interface.
Core abstractions
| Concept | EpiModel name | Notes |
|---|---|---|
| Population | network object of size n, or s.num/i.num counts |
Agents are network vertices; there is no separate person object |
| State | status nodal attribute, values "s", "i", "r", "e", … |
A single string attribute, not a set of typed flags. Custom states are just new string values |
| Other agent state | arbitrary nodal attributes | Untyped R vectors; get_attr/set_attr |
| Transition | a line of R inside a module | There is no transition object. Transitions are procedural code, not data |
| Contact structure | fitted netest object → networkDynamic / edgelist |
What sets EpiModel apart |
| Contact “act” | act.rate parameter |
Acts per partnership per time step, distinct from the partnership itself: an unusually explicit separation of relationship from exposure |
| Time | integer step at, starting at 1 |
No units, no dates, no per-module time step |
| Parameter | element of the param.* list |
Untyped; may be a scalar, or a vector interpreted as a time series or as a sensitivity sweep depending on context |
| Intervention | inter.eff / inter.start built in; otherwise a module, or a scenario updater |
Two mechanisms, see below |
| Output | dat$epi, a named list of per-step vectors |
Written by modules with set_epi(dat, name, at, value) |
| Stochastic replicate | nsims |
Replicates are the unit of parallelism; future/future.apply backend |
Time
Time is an integer counter at, and everything is per time step. There are no units and no calendar: rec.rate = 0.05 means “per step”, and the modeler keeps track of the meaning. Rates and probabilities are all plain numbers too: inf.prob is a per-act probability, act.rate a count per step, and rec.rate a per-step hazard, and the per-step transmission probability is worked out by hand in the infection module as 1 - (1 - transProb)^actRate.
Two consequences matter for the design. Changing dt means rescaling every parameter by hand, and nothing can check that this was done consistently; camdl’s dimensional types catch this kind of error. And the network’s dissolution coefficients are calibrated to a duration in time steps, so the network and the epidemic share one step, and separate module time steps as in Starsim are not possible.
In one place, vector parameters mean something about time: inf.prob and act.rate may be vectors indexed by time since infection, giving stage-dependent infectivity, with the last element carried forward. This is an elegant trick, but its meaning is implicit: the same vector passed to param.dcm() means a sensitivity sweep.
Stochasticity and reproducibility
Base R random numbers, seeded by set.seed() at the top of the script. Replicates share the seed stream when run serially, and use future.seed = TRUE (L’Ecuyer-CMRG streams) in parallel, so parallel runs are reproducible but not identical to serial ones. There are no random streams per module or agent, and so no common random numbers across scenarios: comparing two interventions means averaging over replicates.
Populations and mixing
Three levels, and the differences between them are what the package teaches:
- DCM: no population, just compartment sizes; optionally two groups with a
balanceargument that reconciles mismatched act rates between groups. - ICM: individuals, random mixing, binomial draws.
- Network: individuals with attributes, partnerships from a fitted TERGM. Supports multiple network layers (
num.nw), attribute-dependent formation (nodematch,nodefactor,absdiffand the rest of the ERGM term vocabulary), vital dynamics with arrivals and departures, andtergmLitefor speed on large populations.
Transmission on the network works from the discordant edgelist: at each step, discord_edgelist() enumerates every serodiscordant active partnership, a per-edge probability is computed, and rbinom decides. This is clear and easy to inspect (the unit of transmission is an edge, and the modeler can see the edgelist), and Starsim uses the same approach.
Interventions
Three mechanisms, added over time:
- Built-in:
inter.effandinter.start, a multiplicative reduction in transmission probability from a given step. Convenient, but only one intervention, and only on transmission. - Modules: any
function(dat, at)added to the control list. Fully general, and written as code. - Scenarios and updaters:
param.net()takes.param.updater.listentries ({at, param = list(...)}) that overwrite named parameters at a given step.create_scenario_list()turns a long table ofscenario.id / param / at / valueinto scenarios, anduse_scenario(param, scenario)applies one. Withparam.net_from_table()/param.net_to_table(), scenarios and time-varying parameters can be defined as data.
The third is the interesting one for this project: a small table form for “change parameter p to value v at time t”, which covers a surprising share of real interventions without code. It can only set parameter values, though, so anything targeting a subset of agents (screen 40% of over-50s annually) falls back to a module.
Results
dat$epi is a named list of numeric vectors, one per output with one entry per step, written by whichever module produced it. Built-in models fill in compartment counts (s.num, i.num, …) and flows (si.flow, ir.flow, …). Calling set_epi() with a new name creates a custom output, again with no declaration.
Two additions are worth noting. Trackers (epi.trackers in param) declare a summary statistic as a function of dat, computed each step. And the transmission matrix (get_transmat()) records every infection with infector, infectee, time, and probability, and can be converted to a phylogeny via as.phylo.transmat(). Most frameworks lack this record of each event.
Extraction is as.data.frame(sim), with out = "mean"/"sd"/"vals"; summary(sim, at = t) gives cross-simulation summaries.
Minimal model
Deterministic SIR:
library(EpiModel)
param <- param.dcm(inf.prob = 0.2, act.rate = 5, rec.rate = 1/3)
init <- init.dcm(s.num = 999, i.num = 1, r.num = 0)
control <- control.dcm(type = "SIR", nsteps = 200)
mod <- dcm(param, init, control)
plot(mod)The corresponding stochastic random-mixing model is the same three lines with icm/.icm and nsims = 10, which is the point of the parallel API.
Under the hood the built-in SIR is an ordinary deSolve derivative function, and a custom model is written the same way and passed as control.dcm(new.mod = my_model):
mod_SI_1g_cl <- function(t, t0, parms) {
with(as.list(c(t0, parms)), {
num <- s.num + i.num
lambda <- inf.prob * act.rate * i.num / num
si.flow <- lambda * s.num
list(c(-si.flow, si.flow, si.flow), num = num)
})
}The returned derivatives have to be in the same order as init.dcm(). This link between two function calls is unchecked, which is a good argument for named transitions.
Worked example
The standard example is SIS on a dynamic network, from the package introduction, which uses the parts no other framework has:
library(EpiModel)
set.seed(12345)
# 1. Specify and fit the contact network
nw <- network_initialize(n = 500)
formation <- ~edges + concurrent + degrange(from = 4)
target.stats <- c(175, 110, 0) # mean degree 0.7; limited concurrency; no degree 4+
coef.diss <- dissolution_coefs(dissolution = ~offset(edges), duration = 50)
est <- netest(nw, formation, target.stats, coef.diss)
# 2. Diagnose the network before any disease runs on it
dx <- netdx(est, nsims = 5, nsteps = 500)
plot(dx)
plot(dx, type = "duration")
# 3. Layer the epidemic on top
param <- param.net(inf.prob = 0.4, act.rate = 2, rec.rate = 0.05)
init <- init.net(i.num = 10)
control <- control.net(type = "SIS", nsims = 5, nsteps = 500)
sim <- netsim(est, param, init, control)
# 4. Analyze
plot(sim, sim.lines = TRUE, qnts = 0.5)
d <- as.data.frame(sim)
tm <- get_transmat(sim)For a full research-scale model, EpiModelHIV is the reference: several network layers (main / casual / one-off), age and race driving formation and progression, a full HIV natural history with viral load, and PrEP and ART as modules. At that scale, nearly every module is user-written, and EpiModel provides the network engine, the loop, and the accessor functions.
Strengths
- Contact structure that is fitted and diagnosed. Nothing else in the landscape offers
netest→netdx→netsim, where the network is estimated from target statistics and checked before the epidemic is added. - The same inputs across paradigms.
param/init/controlplus a runner has the same shape for ODE, stochastic random mixing, and networks. The modeler still rewrites the model, but comparison is cheap, and one set of inputs serves three kinds of run. - Simple modules.
function(dat, at) -> datis about as small as a module can be, and can be learned in one sitting. - Partnership kept separate from act.
act.ratewithin a lasting partnership is a distinction most frameworks lose, and it matters for STIs. - A record of each transmission event in the transmission matrix, including a route to a phylogeny.
- Scenario tables (
scenarios.df,param.net_from_table) that define time-varying parameters and scenarios as data. - Exceptional teaching materials: the JSS paper, the SISMID/NME course, and the Gallery of worked extensions. Much of EpiModel’s influence is through teaching, and being explicit serves that.
Limitations
- No typed time. Everything is per step, without units or checks; rates, probabilities, and counts are all
numeric. Changing the step means rescaling by hand. - State is a string.
status == "i"is the whole disease state. Several diseases, co-infection, and independent states (infected × vaccinated × pregnant) are hand-coded as extra attributes, and nothing coordinates them. - Transitions are code, not data. No object represents “S → I at rate λ”. A model cannot be drawn, dimension-checked, or converted to another paradigm without parsing R.
- Nothing is declared: parameters, attributes, and outputs all appear on first use, so typos silently change behavior.
- Order matters between
init.dcm()and the derivatives returned by a custom DCM. - One time step for the whole simulation, shared by the network and every disease.
- No common random numbers, so scenarios cannot be paired, and small intervention effects need many replicates.
- One disease by default. There is one
statusattribute, and co-circulating pathogens are left to the modeler. - Tied to R and statnet. The ERGM tools are the reason to use EpiModel, and also why its models do not easily leave R.
Implications for the lingua franca
- Adopt the three-part input split.
param/init/control(parameters, initial conditions, run settings) is a clean, well-tested split, and it serves ODE, ICM, and network models alike. It matches camdl’sparameters/init/simulateblocks almost exactly. - Make the network something a modeler can describe, not just an input. EpiModel’s strongest idea is that a contact structure can be described by target statistics (mean degree, concurrency, duration, mixing) and then generated. An edgelist cannot say “mean degree 0.7, partnerships lasting 50 days”, and that description is what modelers have. Whether the language should include this is a real design question.
- Keep partnership and act separate. Length of relationship and acts per unit time are different quantities with different data sources. A single “contact rate” loses this for anything sexually transmitted.
- Do not repeat untyped time. EpiModel is otherwise well designed, and each of its time-related problems comes from
numericbeing the only type. It is the clearest case in the review for dimensional types. - Make transitions data. The largest gap between EpiModel and this project’s goals is that a model is a set of R functions. To draw a model, convert it to ODEs, check it, or generate it from prose, a transition has to be an object that software can read.
- Take the scenario table, then extend it.
{scenario, param, at, value}is a good minimal form for time-varying parameters and scenarios. Anything targeting a subset of agents falls back to code, which shows what is missing: interventions need an eligibility expression, as well as a parameter and a time. - Record transmission events. The transmission matrix is cheap, generally useful, and allows phylodynamic comparison. It should be a standard result.
- Note what the shared inputs do not do. EpiModel gives three paradigms with one set of inputs, but three separately written models. This project aims for the same inputs and the same model.