Skip to content

Commit 175c27d

Browse files
committed
Merge contact tracing example
2 parents 6f004fe + e65d2b8 commit 175c27d

5 files changed

Lines changed: 1428 additions & 0 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# SEIR with Contact Tracing for an Acute, Immunizing Infection
2+
3+
## Description
4+
5+
This example demonstrates **contact tracing as a network intervention** for an acute, immunizing, COVID-like infection. The disease model is an SEIR with a presymptomatic infectious split (`Ip` then `Is`), so transmission can occur in a window before symptoms appear. The custom `trace` module reads EpiModel's cumulative edgelist to walk recent contacts of each newly diagnosed index case, applies a Bernoulli reach probability, and quarantines the contacts it reaches.
6+
7+
The pedagogical core of the example is the **cumulative-edgelist API pattern** (`cumulative.edgelist = TRUE` in `control.net()`, then `get_partners()` plus `get_posit_ids()` inside a module). The mechanism generalizes to any partner-services intervention: contact tracing, partner notification, expedited partner therapy, or ring vaccination.
8+
9+
## Model Structure
10+
11+
### Disease Compartments
12+
13+
| Status | Description |
14+
|--------|-------------|
15+
| `s` | Susceptible |
16+
| `e` | Exposed (latent, not yet infectious) |
17+
| `ip` | Presymptomatic infectious (transmits, not detectable) |
18+
| `is` | Symptomatic infectious (transmits, detectable by symptoms) |
19+
| `r` | Recovered, immune |
20+
21+
### Flow Diagram
22+
23+
```mermaid
24+
flowchart LR
25+
S["<b>S</b><br/>Susceptible"] -->|"infection<br/>(se.flow)"| E
26+
E["<b>E</b><br/>Exposed"] -->|"ei.rate"| Ip
27+
Ip["<b>I_p</b><br/>Presymp."] -->|"ips.rate"| Is
28+
Is["<b>I_s</b><br/>Symp."] -->|"isr.rate"| R["<b>R</b><br/>Recovered"]
29+
Is -.->|"dx.rate.symp<br/>(triggers trace)"| Is
30+
31+
style S fill:#3498db,color:#fff
32+
style E fill:#8e44ad,color:#fff
33+
style Ip fill:#f39c12,color:#fff
34+
style Is fill:#e74c3c,color:#fff
35+
style R fill:#27ae60,color:#fff
36+
```
37+
38+
`ip` and `is` are stored as the `status` attribute values directly, so the compartment counts read cleanly from a single attribute and a parallel `inf.stage` carries the same information for any module that needs the substage.
39+
40+
### The Contact-Tracing Pattern
41+
42+
The headline module pattern is the cumulative-edgelist round-trip. Inside the `trace` module:
43+
44+
```r
45+
# 1. Indices whose diagnosis is trace.delay steps old this step.
46+
idsIndex <- which(active == 1 &
47+
!is.na(dx.time) &
48+
(at - dx.time) == trace.delay)
49+
50+
# 2. get_partners takes positional ids, returns unique ids.
51+
part_df <- get_partners(dat, idsIndex,
52+
truncate = trace.lookback,
53+
only.active.nodes = TRUE)
54+
55+
# 3. Convert partner unique ids back to positional ids before
56+
# indexing into the attribute vectors.
57+
partner_pid <- get_posit_ids(dat, part_df$partner)
58+
partner_pid <- partner_pid[!is.na(partner_pid)]
59+
```
60+
61+
The unique-id round trip is the gotcha future readers will trip over: `get_partners()` returns `unique` ids in the partner column because a partner may have departed since the partnership existed, but the simulation's attribute vectors are indexed by positional ids. Translating with `get_posit_ids()` is the safe pattern.
62+
63+
Three `control.net()` switches activate the machinery:
64+
65+
| Switch | Effect |
66+
|--------|--------|
67+
| `cumulative.edgelist = TRUE` | Build a running history of dissolved (and active) edges |
68+
| `truncate.el.cuml = trace.lookback` | Drop edges older than this many steps (memory bound) |
69+
| `save.cumulative.edgelist = TRUE` | Attach the final history to the returned sim |
70+
71+
## Modules
72+
73+
### Attribute Initializer (`init_attrs`)
74+
75+
Lazily allocates the auxiliary tracing attributes on first call: `dx.time`, `dx.this.step`, `quar.until`, `traced.count`, and `inf.stage`. Seed infections (`status == "i"`) are placed in the presymptomatic substage so they progress through the same pipeline as later secondary cases.
76+
77+
### Infection (`infect`)
78+
79+
Walks the cross-sectional edgelist and identifies discordant edges (one `s`, one `ip` or `is`). The per-edge effective act rate is multiplied by `quar.act.mult` if either endpoint is currently quarantined (`at <= quar.until`). This single mechanism captures both index isolation (post-diagnosis) and traced-contact quarantine. Records `se.flow`.
80+
81+
### Progression (`progress`)
82+
83+
Snapshot-based stage transitions: E to Ip at `ei.rate`, Ip to Is at `ips.rate`, Is to R at `isr.rate`. Symptomatic, undiagnosed Is cases are tested at `dx.rate.symp` per step; on diagnosis a node receives `dx.time = at`, `dx.this.step = 1`, and `quar.until = at + iso.duration` (index isolation). Records `ei.flow`, `ips.flow`, `ir.flow`, `dx.flow`, and the cross-sectional compartment counts.
84+
85+
### Prevalence (`prev`)
86+
87+
A minimal replacement for `prevalence.net` that correctly counts `i.num = ip.num + is.num` (the built-in counts `status == "i"`, which is never true in this parameterization).
88+
89+
### Tracing (`trace`)
90+
91+
The headline module. For each diagnosed index whose `at - dx.time == trace.delay`, calls `get_partners()` over the cumulative edgelist with `truncate = trace.lookback`, converts the returned unique ids to positional ids, drops contacts who are already diagnosed, and Bernoulli-thins by `trace.reach.prob`. Reached contacts have their `quar.until` extended to `at + quar.duration`. Records `trace.idx.flow`, `trace.reach.flow`, `trace.quar.flow`, and the running `quar.num`.
92+
93+
## Parameters
94+
95+
### Disease
96+
97+
| Parameter | Description | Default |
98+
|-----------|-------------|---------|
99+
| `inf.prob` | Per-act transmission probability | 0.05 |
100+
| `act.rate` | Acts per partnership per day | 2 |
101+
| `ei.rate` | E to Ip transition rate (mean 3 days latent) | 1/3 |
102+
| `ips.rate` | Ip to Is transition rate (mean 2 days presymptomatic) | 1/2 |
103+
| `isr.rate` | Is to R transition rate (mean 6 days symptomatic) | 1/6 |
104+
| `dx.rate.symp` | Per-day diagnosis rate for undiagnosed Is | 0.5 |
105+
| `iso.duration` | Days of index isolation after diagnosis | 10 |
106+
107+
### Tracing
108+
109+
| Parameter | Description | Default |
110+
|-----------|-------------|---------|
111+
| `trace.reach.prob` | Per-partner probability of successful contact | 0.0 (no tracing) |
112+
| `trace.delay` | Days from index diagnosis to contact reach | 0 |
113+
| `trace.lookback` | Days of partner history traversed | 3 |
114+
| `quar.duration` | Days a reached contact stays quarantined | 10 |
115+
| `quar.act.mult` | Act-rate multiplier during quarantine | 0.1 |
116+
117+
### Network
118+
119+
A single-layer dynamic contact network with mean degree 3 and mean partnership duration 10 days (treat each step as 1 day). Network is intentionally simple so the focus stays on the tracing mechanism.
120+
121+
| Parameter | Default |
122+
|-----------|---------|
123+
| Population size | 500 (interactive); 200 (CI) |
124+
| Target edges | 1.5 x n (mean degree 3) |
125+
| Partnership duration | 10 days |
126+
127+
## Module Execution Order
128+
129+
```
130+
resim_nets -> summary_nets -> initAttr -> infection -> progress -> trace -> nwupdate -> prevalence
131+
```
132+
133+
The trace module reads `dx.this.step` (set by progress) and writes `quar.until`; the next step's infection module reads that `quar.until` to apply the act-rate multiplier.
134+
135+
## Scenarios
136+
137+
All four scenarios share the same fitted network, the same disease parameters, and 10 seed infections. They differ only in the tracing configuration.
138+
139+
| Scenario | Configuration | Teaching point |
140+
|----------|---------------|----------------|
141+
| No tracing | `trace.reach.prob = 0` | Counterfactual; indices still self-isolate |
142+
| Fast + high | reach 80%, delay 1 day | The benchmark for an aggressive program |
143+
| Slow + high | reach 80%, delay 4 days | Delay alone kills the effect even at high reach |
144+
| Fast + low | reach 30%, delay 1 day | Speed without coverage |
145+
146+
## Next Steps
147+
148+
- **Sweep the lookback window.** Cumulative-edgelist size scales with `trace.lookback`. Vary it across the partnership-duration range to find the marginal value of going further back in time.
149+
- **Add provider-side capacity limits** by capping the number of traced indices per day or budgeting tracer staff hours.
150+
- **Combine with vaccination.** Layer the time-varying vaccination pattern from [SIR with Time-Varying Vaccination](../sir-time-varying-vaccination) on top of contact tracing.
151+
- **Multilayer extension.** Run the same trace module over a household + casual two-layer network (see [Multinets](../multinets) and [RSV](../rsv) for layered patterns).
152+
- **Pre-exposure prophylaxis for reached contacts** instead of (or in addition to) quarantine: at the reach event, advance the contact's status to a protected state for the duration.
153+
154+
## Author
155+
156+
Samuel M. Jenness, Emory University (http://samueljenness.org/)

0 commit comments

Comments
 (0)