|
| 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