Interventions
Recommendation
- An intervention is what plus delivery. What is a product (a vaccine, a test, a treatment) or a change to a quantity. Delivery is who gets it, when, and how many. The “what” separates cleanly; who, when, where, and how many often do not separate from each other, so delivery is either a few simple keywords or one function.
- Eligibility is written against the model’s real attributes. The general form is a function of the sim, as in Starsim 3. The short form is the same expression written directly on the people and disease objects (
who=ss.person.age < 5), which is evaluated later when the sim runs. There is no separate vocabulary of condition names. - Keep Starsim’s product/delivery split, and separate efficacy from waning as EMOD does.
- Many interventions are an
ss.transferbetween strata or anss.modifyon a named quantity. Do not build a class for what is already one of those. - The simple case stays a one-liner. Covasim’s
days/changesform was simpler than what replaced it, and it comes back asss.modifyof a time series (multiply=ss.timeseries({'2020-03-15': 0.5, '2020-06-01': 1.0})). - Coverage as a fraction and capacity as a number are different things, and both are needed:
coverage=0.7andcapacity=50_000. - Interventions carry a cost. This idea comes from Atomica, the only framework in the review that does this, and it is what the policy audience needs. With a cost, total cost is a result, and
sim.optimize()can reallocate spending between programs (as the Atomica importer’s program books do). - Scenarios live in the model file, not in a shell script, and a baseline contains whatever is really in place.
Why
The split that runs through the whole review
Finding 1 of the landscape review: “Structural rigor and intervention expressiveness have not yet appeared in the same framework.” camdl, odin, summer2, and epipack have the most rigorous model specification, with few words for interventions. EMOD, EpiHiper, Starsim 3, and Atomica have the richest words for interventions, with less formal model structure.
Closing that split is, per the review, “the most concrete contribution available to this project.”
What separates and what does not
EMOD decomposes a campaign event into four pieces:
| Part | Question | EMOD |
|---|---|---|
| Where | Spatial scope | Nodeset_Config |
| When, how many, how often | Schedule and coverage | Event_Coordinator_Config |
| To whom | Eligibility | Targeting_Config |
| What | The product | Intervention_Config + Waning_Config |
This is the most complete treatment in the review and a very useful checklist. As a design, the tradeoff is that the pieces are less independent than the four parts suggest. Consider a realistic policy: distribute treatment drugs to the 100 clinics with the most positive cases, while stocks last. Where, when, how many, and to whom are all functions of who tested positive. Only “what” (the drug) can be swapped without touching the rest.
So the lingua franca keeps two parts. Starsim 3 already had this split (ss.Product and the RoutineDelivery / CampaignDelivery classes), and its review says “the product/delivery split is the right decomposition and should survive into the design.”
Eligibility in existing frameworks
Starsim takes eligibility as a function of the sim that returns who is eligible. That is fully general, works with any custom attribute, and is the form to keep. Its limits, per its review: “it is Python, so it cannot be serialized, diagrammed, or checked.”
EpiModel’s tabular scenario grammar, {scenario, param, at, value}, covers a large fraction of real specifications without any code, and its limit is that “it can only set parameter values, so anything that targets a subset of agents (screen 40% of over-50s annually) has to fall back to procedural code.” flepiMoP’s modifiers have the same shape and the same ceiling.
Two frameworks have eligibility as data. EMOD:
"Targeting_Config": {
"class": "TargetingLogic",
"Logic": [ [ {"class": "HasIntervention", "Is_Equal_To": 0, "Intervention_Name": "MyVaccine"} ],
[ {"class": "HasIP", "Is_Equal_To": 1, "IP_Key_Value": "Risk:HIGH"} ] ]
}EpiHiper has set algebra over typed node and edge predicates, “the most expressive targeting mechanism in the review”, at the cost of a separate rule language. And individual shows the same algebra in code, with bitsets:
I <- health$get_index_of("I")
I$and(already_scheduled$not(inplace = TRUE))The aim is to get eligibility that reads like the individual version, can be saved like the EMOD version when it is simple, and falls back to a plain function when it is not.
The specification
08-interventions.md specifies products (ss.Vaccine, ss.Diagnostic, ss.Treatment, with efficacy=, waning=, and cost=), the deliveries (ss.vaccinate, ss.screen, ss.treat) with who= (who is eligible: a condition, or a function of the sim) and when= (times, an event, or ss.schedule()), follow-ups and event-triggered delivery (when=screening.positive), changes at given times, triggers (who= on the population, and until= on ss.modify), scenarios (ss.parallel({'Baseline': base, 'Vaccine': vx})), and how each solver realizes an intervention. Eligibility as conditions on the real attributes, and ss.modify for a change to a named quantity, are in 07-effects.md; vaccination as an ss.transfer between strata is in 05-dimensions.md. The docs show how to set these up in Interventions, Products, and Costs.
Trade-offs
- Conditions that evaluate later are an implementation cost, and a possible source of confusion. The same expression builds a condition before the sim is initialized and returns an array after. The rule is simple to state, but error messages have to be good, and
describe()should show each condition as written. - A function cannot be drawn or compared. Eligibility or delivery written as a function is printed by name in
describe(), which says that a function was used, not what it does. This is accepted, because the function is the general form. - A condition on a per-agent attribute has no compartmental meaning unless the attribute is a stratification.
ss.person.age > 65works in both if the model is stratified by age with a boundary at 65;ss.person.clinic == 12works only with agents.solver='auto'then chooses agents, and an explicit compartmental solver stops withss.CapabilityError, naming the attribute. - A function of the sim in
who=is evaluated mid-run, on results so far. The function reads a results array that is only filled up to the current step, so it has to index bysim.ti(a bare[-7:]would read the unfilled end of the array), and what it sees depends on where in the loop the modifier runs. It is kept because a stateless threshold is common and short. The commonest case, a threshold on recent incidence, is a condition with no indexing at all (who=sir.infection.incidence(over=ss.days(7)) > 1e-3), anduntil=keeps the modifier on for a while once it holds; anything subtler belongs in a class. who=fnhides structure. A function of the sim can do anything, so two interventions using one cannot be compared keyword by keyword. Use the keywords when they fit.- Products are a vocabulary that will grow. Vaccines, tests, treatments, bednets, condoms, contact tracing. Most are an
ss.modifyon a named quantity with a coverage and an eligibility, and only the ones with distinct structure (a diagnostic returns a result) need a class.
Rejected
- Four independent parts (EMOD’s decomposition taken literally). Where, when, how many, and to whom depend on each other in real programs, so there are two parts: what, and delivery.
- A vocabulary of global condition names (
ss.age > 65,ss.weekday == 'Fri'). It reads well for the built-in attributes, but custom ones would need something like Polars’pl.col('name'), and the list of names tends to grow. The real attributes on the real objects need no list. - A class per combination of intervention type and delivery type. Starsim 3’s
routine_screening/campaign_screening/routine_triage/campaign_triage/routine_vx/campaign_vxis two axes spelled out as classes (removed in Starsim 4). Keyword arguments, one function each. - Interventions as contact reductions only (Epydemix, MEmilio). Well suited to COVID-era scenarios, and narrower than this project needs.
- Interventions as extra compartments written by hand (Epydemix’s answer for vaccination, MEmilio’s
secirvvs). This is whatss.transferbetween strata replaces, and it is why vaccination need not touch the disease model. 'no -> yes'arrows for booleans.ss.Delivery(dict(vaccinated=True), ...).min_duration=onss.modify, and any other trigger keyword that needs memory. State that persists between steps belongs where it can be seen and tested: in astep()method or an intervention class. (One such keyword was later added:until=, as a duration the minimum time a modifier stays on once its condition holds, which MEmilio’s dynamic NPIs need; any other memory is still astep().)- A separate rule language (EpiHiper’s variables/sets/triggers/operations). What it can express is the richest in the review; the tradeoff is length, at 200 lines of JSON for one Friday.
- Edge-level eligibility (EpiHiper’s edge predicates). Networks are enough: to change school contacts, modify the school network.
- Interventions that are inactive unless a scenario enables them (camdl). Baselines usually contain real interventions; each scenario lists what it contains.
- Budget optimization in v1 (Atomica, PyRoss). Do not rule it out; do not build it yet. (Since built for programs with spending,
ss.fitting.optimize(), to match Atomica; optimal control is still out.)
Open questions
The open questions are collected in 19-open-questions.md.