Hospital transmission routes
When sequencing links patients' infections into a cluster, which shared unit, room, procedure, device or care team most likely carried it?
Summary
We give hospital infection-prevention teams a ranked, explained list of likely transmission routes for a genomic cluster. HeapDB ranks routes per patient and for the whole cluster, using the exposures already in the EHR. Patients who shared an exposure and did not become infected count against it.
The question comes up when whole-genome sequencing places two or more patients' isolates in one cluster. Today the team reviews unit stays and procedure logs by hand, and the busiest units dominate any simple count. With HeapDB, the shortlist updates as cases and adjudications arrive, weighs timing, and shows why each route ranks where it does.
Model
| HeapDB | Hospital transmission routes |
|---|---|
| Entity | A patient (a pseudonymous id inside the hospital's environment). |
| Events | From the EHR: one unit_day event per patient per day on a unit, and one procedure event per procedure. |
| Features | unit:<id>, room:<id> (room or bed), team:<id> (a pseudonymous care-team id, not a person) on unit-days; proc:<type>, proc_room:<id> and scope:<id> (a device or piece of equipment) on procedures. |
| Weight (dose) | 1.0 per unit-day and per procedure in the example. Duration of a stay or a device dwell time could be used where recorded. |
| Effects and kernels | cluster_infection (a positive culture whose isolate belongs to the cluster), LogNormalKernel(median=5 * D, sigma=0.5, tail_sigmas=3.0). Exposure to a positive culture takes days to a couple of weeks, with a long right tail; this kernel truncates near 22 days. Registered with repeatable=False: only a patient's first cluster culture counts. |
| Leak | leak=0.5: cluster cases with no recorded route (missed exposures, carriage from before admission). |
| Rules | None in the example. A rule could down-weight single-use equipment, which cannot carry a strain between patients. |
Learning
- Confirms (
feedback): infection prevention adjudicates a case and records the route it found, such as a procedure in a given room. That confirms the event; cleared exposures can be passed asrejected_ids. In the example 5 cases get this. - Observed without labels (
observe): every other cluster patient's culture. Each exposure in the kernel window gets a soft share of credit in proportion to its current score; the leak keeps a share for "none of these". - Settles (
settle): patients exposed to the same units, rooms, procedures and devices who had no cluster culture give negative evidence. "The window passed" means the exposure is older than the kernel's support, about 22 days here. The example settles nightly. - Priors: the default Beta(1, 4) for every feature.
global_pseudocount=1e6makes each patient borrow the whole hospital's evidence, so per-patient rankings reflect the cluster, not one stay.
Sidecar architecture
The EHR stays the system of record, along with the sequencing lab's cluster calls. ADT movements, procedure records and device tracking reach HeapDB through the hospital's existing interface feed or a change-data-capture stream; cluster membership arrives from the genomic surveillance system as isolates are called. HeapDB writes back only the ranked routes and their explanations, to the infection-prevention case file. It runs inside the hospital's environment, holds no data of its own, and can be rebuilt by replaying the feed.
Worked example
In the simulation, 1500 patients pass through a hospital with one large ICU and six wards over 120 days. One interventional radiology suite, proc_room:ir-2, is contaminated; it serves patients from every unit, so the cases are spread across wards. Severely ill patients start in the ICU, get more procedures, and have more cluster cases with no recorded route: the ICU is an honest confounder.
Output of 0006-hospital-transmission-routes.py
SCENARIO
1500 patients, 120 days, 9,509 unit-days, 1,660 procedures (121 in ir-2)
43 cluster cases from 6 home wards; 5 adjudicated by infection prevention
UNIT-BASED REVIEW (cases that passed through each unit)
unit:icu 30, unit:4e 13, unit:6w 8, unit:5w 7
ONE PATIENT, EXPLAINED (pt-0061, home ward unit:6e, culture day 75.7)
Top causes of 'cluster_infection' for pt-0061 at t=2026-07-15T17:21:52Z:
1. #7010 procedure@2026-07-12T08:01:35Z [proc:ir-drain,proc_room:ir-2] w=1 score=0.0969
lag 81.34h -> kernel 0.962 x weight 1 x link 0.101 [noisy-OR of proc:ir-drain+proc_room:ir-2=0.10 inseparable]
2. #6990 unit_day@2026-07-12T04:01:35Z [room:icu-03,team:icu-b,unit:icu] w=1 score=0.0248
lag 85.34h -> kernel 0.984 x weight 1 x link 0.025 [noisy-OR of room:icu-03+unit:icu=0.02 inseparable, team:icu-b=0.00 (a=1.53, b=338.11)]
warning: 'room:icu-03', 'unit:icu' always co-occur (5 events): feedback cannot separate their effects yet; reported as joint cause room:icu-03+unit:icu
3. #6902 unit_day@2026-07-11T04:01:35Z [room:icu-03,team:icu-b,unit:icu] w=1 score=0.0240
lag 109.34h -> kernel 0.952 x weight 1 x link 0.025 [noisy-OR of room:icu-03+unit:icu=0.02 inseparable, team:icu-b=0.00 (a=1.53, b=338.11)]
warning: 'room:icu-03', 'unit:icu' always co-occur (5 events): feedback cannot separate their effects yet; reported as joint cause room:icu-03+unit:icu
[segments: 2 scanned / 5 pruned / 7 in window; events scored: 4]
ACROSS ALL CASES (route features, global links)
Links of 'cluster_infection' for (global) (baseline 0.007; associations under the model, not causal effects):
feature mean 90% CI lift n alpha beta
proc_room:ir-2 0.074 [0.036, 0.122] 10.53 90.9 7.11 88.81
proc:ir-biopsy 0.034 [0.009, 0.072] 4.79 76.5 2.75 78.80
proc:ir-drain 0.029 [0.005, 0.068] 4.08 61.6 1.91 64.65
proc:ir-picc 0.025 [0.004, 0.060] 3.52 68.0 1.81 71.20
proc_room:ir-1 0.009 [0.001, 0.026] 1.30 115.2 1.10 119.09
proc:endoscopy 0.007 [0.001, 0.018] 0.94 181.9 1.24 185.63
proc_room:endo-1 0.007 [0.001, 0.018] 0.94 181.9 1.24 185.63
team:6e-c 0.007 [0.000, 0.019] 0.93 153.1 1.04 157.08
CHECK
seeded route ir-2 ranks #1 of 38 route features (lift 10.53)
busy unit:icu ranks #27 (lift 0.28), though 30 of 43 cases passed through it
ir-2 is the top cause for 29/30 cases with an ir-2 procedure in the windowShow the full script (215 lines)
"""Hospital transmission routes: which shared unit, room, procedure or device links a cluster?
Simulates 120 days of admissions at a 7-unit hospital (one large ICU, six
wards) for about 1,500 patients. The EHR feed gives HeapDB one event per
patient-day on a unit (unit, room, pseudonymous care team) and one event per
procedure (procedure type, procedure room, and the endoscope id when one is
used). Genome sequencing places some positive cultures in one cluster; each
is a cluster_infection effect. HeapDB ranks candidate routes per patient
(top_causes) and across the cluster (link_report).
Hidden ground truth (the seeded cause):
* proc_room:ir-2, one of two interventional radiology suites, is
contaminated. Each procedure done there infects the patient with the
cluster strain with probability 0.20 (x1.5 for severely ill patients).
Lag to the positive culture: log-normal, median 5 d, sigma 0.5.
* IR patients come from every unit, so the cases are spread across wards
and a unit-by-unit review does not see one hot spot.
Distractors and an honest confounder:
* Severity. Severely ill patients start in unit:icu, get more procedures,
and are more susceptible. They also have more cluster cases with no
recorded route (2.5% vs 0.4%: missed exposures, community carriage). The
ICU has the most cases, but it is not the route.
* Common procedures (CT, central lines, endoscopy with 8 scopes) and the
IR procedure types, which are done in both suites.
Learning: every cluster case is observe()d (no labelled route); infection
prevention adjudicates 5 cases with a confirmed ir-2 exposure via
feedback(); patients whose exposure windows pass without a cluster culture
are settle()d (negative evidence).
Run:
pip install heapdb
python 0006-hospital-transmission-routes.py
"""
from __future__ import annotations
import math
import random
from heapdb import HeapDB, LogNormalKernel, format_result
M, H, D = 60.0, 3600.0, 86400.0
SEED = 11
T0 = 1777593600.0 # 2026-05-01T00:00:00Z; the simulation runs in seconds after T0
N_PATIENTS = 1500
DAYS = 120
WARDS = ["unit:4w", "unit:4e", "unit:5w", "unit:5e", "unit:6w", "unit:6e"]
ICU = "unit:icu"
SEEDED = "proc_room:ir-2"
IR_TYPES = ["proc:ir-drain", "proc:ir-picc", "proc:ir-biopsy"]
SCOPES = [f"scope:e{i:02d}" for i in range(1, 9)]
ATTACK = 0.20 # P(cluster infection | one procedure in the contaminated suite)
SEVERE_MULT = 1.5 # severely ill patients are more susceptible
BACKGROUND = {True: 0.025, False: 0.004} # cluster cases with no recorded route
N_ADJUDICATED = 5 # cases infection prevention reviews and confirms
def lag(rng: random.Random) -> float:
return math.exp(rng.gauss(math.log(5 * D), 0.5))
def simulate(rng: random.Random):
"""Return (EHR events, first cluster culture per patient, severity, home ward)."""
events, cultures, severe_of, ward_of = [], {}, {}, {}
for p in range(N_PATIENTS):
who = f"pt-{p:04d}"
severe = rng.random() < 0.25
ward = rng.choice(WARDS)
severe_of[who], ward_of[who] = severe, ward
admit = rng.uniform(0, DAYS - 10) * D
los = min(30, max(2, round(math.exp(rng.gauss(math.log(7 if severe else 5), 0.5)))))
icu_days = rng.randint(2, 6) if severe else (rng.randint(1, 3) if rng.random() < 0.05 else 0)
icu_days = min(icu_days, los - 1)
first = math.inf
room = None
for d in range(los):
t = admit + d * D + 7 * H
unit = ICU if d < icu_days else ward
if d == 0 or d == icu_days:
n_rooms = 24 if unit == ICU else 16
room = f"room:{unit.split(':')[1]}-{rng.randint(1, n_rooms):02d}"
team = f"team:{unit.split(':')[1]}-{'abc'[(d // 3) % 3]}"
events.append((t, who, "unit_day", {unit, room, team}))
# Procedures that day (sicker patients get more of everything).
if rng.random() < (0.15 if severe else 0.06):
events.append((t + 3 * H, who, "procedure", {"proc:ct", f"proc_room:ct-{rng.randint(1, 2)}"}))
if severe and d < icu_days and rng.random() < 0.25:
events.append((t + 2 * H, who, "procedure", {"proc:central-line"}))
if rng.random() < (0.05 if severe else 0.015):
suite = rng.choice(["proc_room:ir-1", SEEDED])
events.append((t + 4 * H, who, "procedure", {rng.choice(IR_TYPES), suite}))
if suite == SEEDED and rng.random() < ATTACK * (SEVERE_MULT if severe else 1.0):
first = min(first, t + 4 * H + lag(rng))
if rng.random() < 0.02:
events.append((t + 5 * H, who, "procedure",
{"proc:endoscopy", "proc_room:endo-1", rng.choice(SCOPES)}))
if rng.random() < BACKGROUND[severe]:
first = min(first, admit + rng.uniform(1, los + 7) * D)
if first < DAYS * D:
cultures[who] = first
events.sort(key=lambda e: e[0])
return events, cultures, severe_of, ward_of
def make_db() -> HeapDB:
db = HeapDB(bucket_seconds=D)
# Exposure-to-culture lag for a hospital-acquired strain: days to a couple of
# weeks. Log-normal, median 5 d, truncated at median * exp(3 sigma) (~22 d).
# First cluster culture only (repeatable=False). A large global_pseudocount lets
# each patient borrow the population's evidence: the cohort view of all exposures.
db.register_effect("cluster_infection",
LogNormalKernel(median=5 * D, sigma=0.5, tail_sigmas=3.0),
leak=0.5, repeatable=False, cause_types={"unit_day", "procedure"},
global_pseudocount=1e6)
return db
ROUTE_PREFIXES = ("unit:", "proc:", "proc_room:", "scope:", "team:")
def main() -> None:
rng = random.Random(SEED)
events, cultures, severe_of, ward_of = simulate(rng)
db = make_db()
stream = [(ev[0], 0, ev) for ev in events] + [(t, 1, who) for who, t in cultures.items()]
stream.sort(key=lambda x: (x[0], x[1]))
by_pt: dict[str, list] = {}
adjudicated, day = [], 0
for t, kind, item in stream:
while t >= (day + 1) * D: # nightly: settle exposure windows that have passed
day += 1
db.settle(T0 + day * D)
if kind == 0:
_, who, typ, feats = item
by_pt.setdefault(who, []).append(db.insert(who, T0 + t, typ, feats))
continue
who = item
db.observe(who, "cluster_infection", T0 + t)
ir2 = [e for e in by_pt.get(who, []) if SEEDED in e.features and 0 < T0 + t - e.time < 20 * D]
if ir2 and len(adjudicated) < N_ADJUDICATED and day > 30: # once a review is underway
# Infection prevention reviews the case and confirms the IR suite exposure.
db.feedback(who, "cluster_infection", T0 + t, confirmed_event_id=ir2[-1].id)
adjudicated.append(who)
db.settle(T0 + (DAYS + 25) * D)
n_proc = sum(1 for e in events if e[2] == "procedure")
n_ir2 = sum(1 for e in events if SEEDED in e[3])
case_wards = {ward_of[w] for w in cultures}
print("SCENARIO")
print(f" {N_PATIENTS} patients, {DAYS} days, {len(events) - n_proc:,} unit-days, "
f"{n_proc:,} procedures ({n_ir2} in ir-2)")
print(f" {len(cultures)} cluster cases from {len(case_wards)} home wards; "
f"{len(adjudicated)} adjudicated by infection prevention")
# Baseline: the unit-based review. Which unit did the most cases pass through?
per_unit: dict[str, int] = {}
for who in cultures:
for u in {f for e in by_pt.get(who, []) for f in e.features if f.startswith("unit:")}:
per_unit[u] = per_unit.get(u, 0) + 1
top_units = sorted(per_unit.items(), key=lambda kv: (-kv[1], kv[0]))[:4]
print()
print("UNIT-BASED REVIEW (cases that passed through each unit)")
print(" " + ", ".join(f"{u} {n}" for u, n in top_units))
# One patient, explained: an unadjudicated ward patient whose window includes
# ICU days and an ir-2 procedure.
pick = None
for who in sorted(cultures):
w = [e for e in by_pt.get(who, []) if 0 < T0 + cultures[who] - e.time < 20 * D]
fs = {f for e in w for f in e.features}
if who not in adjudicated and {ICU, SEEDED} <= fs:
pick = who
break
if pick is None:
pick = sorted(cultures)[0]
print()
print(f"ONE PATIENT, EXPLAINED ({pick}, home ward {ward_of[pick]}, "
f"culture day {cultures[pick] / D:.1f})")
print(format_result(db.top_causes(pick, "cluster_infection", T0 + cultures[pick],
k=3, explain=True)))
routes = sorted({f for e in events for f in e[3] if f.startswith(ROUTE_PREFIXES)})
rep = db.link_report("cluster_infection", min_support=40, features=routes)
print()
print("ACROSS ALL CASES (route features, global links)")
lines = str(rep).splitlines()
print("\n".join(lines[:10]))
hit = tot = 0
for who, t in cultures.items():
if any(SEEDED in e.features and 0 < T0 + t - e.time < 20 * D for e in by_pt.get(who, [])):
tot += 1
top = db.top_causes(who, "cluster_infection", T0 + t, k=1).top
hit += top is not None and SEEDED in top.event.features
feats = [r.feature for r in rep]
seed_rank, icu_rank = feats.index(SEEDED) + 1, feats.index(ICU) + 1
print()
print("CHECK")
print(f" seeded route ir-2 ranks #{seed_rank} of {len(feats)} route features "
f"(lift {rep.row(SEEDED).lift:.2f})")
print(f" busy {ICU} ranks #{icu_rank} (lift {rep.row(ICU).lift:.2f}), "
f"though {per_unit[ICU]} of {len(cultures)} cases passed through it")
print(f" ir-2 is the top cause for {hit}/{tot} cases with an ir-2 procedure in the window")
if seed_rank != 1 or icu_rank == 1:
raise SystemExit(1)
if __name__ == "__main__":
main()- A unit-based review points the wrong way: 30 of the 43 cases passed through
unit:icu, more than any ward (unit:4eis next with 13). The cases come from 6 home wards, so no ward stands out either. - Across all cases,
proc_room:ir-2leads with mean link 0.074, a 90% credible interval of [0.036, 0.122] and lift 10.53. The ICU ranks #27 with lift 0.28: many ICU patients without a procedure in ir-2 stayed uninfected, and settle counts that against it. - The IR procedure types follow at #2 to #4 (
proc:ir-biopsylift 4.79), because each is done partly in ir-2. The other suite,proc_room:ir-1, has lift 1.30, which points at the room rather than the procedure. - For pt-0061, a ward patient who also had ICU days, the ir-2 drain procedure scores 0.0969 against 0.0248 for the best ICU day. The patient had only one IR procedure, so HeapDB reports
proc:ir-drain+proc_room:ir-2as one inseparable cause; only the cohort view separates them. - Per patient, ir-2 is the top cause for 29/30 cases with an ir-2 procedure in the window.
Run it:
pip install heapdb
python 0006-hospital-transmission-routes.py # copy the script from "Show the full script" aboveEvaluation
- Metric: the rank of the adjudicated route among route features in
link_report, and the share of cluster cases whose top cause matches the adjudicated route. - Baseline: the unit-based review, counting cluster cases per unit and room. HeapDB must rank the true route higher, and must not put high-traffic units first only because sick patients pass through them.
- Ground truth: retrospective clusters whose route infection prevention confirmed (by environmental cultures, device sampling or a closed investigation), replayed in time order. Rankings are read only from data available at each point.
Limits and risks
- Decision support only. Infection preventionists decide which route to investigate and what to close, clean or change.
- Rankings are associations under the model, not causal effects. Severity confounds: sicker patients are more exposed to everything and more susceptible, as the ICU shows in the example. Severity could be modeled, but this example does not.
- Two exposures that always co-occur for a patient cannot be separated by that patient's record, and HeapDB says so. Rare devices have wide intervals and can rank high on little evidence.
- EHR timestamp quality limits the timing term. Late documentation, batch-charted procedures and missing device ids move or hide exposures.
- Patient data is protected health information. Any clinical use needs regulatory and privacy review first, data minimization, access controls, and retention limits. Care-team features should stay pseudonymous team ids, never named staff.
- HeapDB does not do sequencing or cluster calling. A wrong cluster call feeds wrong effects.
Prior art
Hospitals already combine genomic surveillance with EHR data to find transmission routes. EDS-HAT, developed by University of Pittsburgh and Carnegie Mellon researchers and run at UPMC Presbyterian Hospital, pairs whole-genome sequencing surveillance with machine learning over the EHR. From November 2016 to November 2018 it detected 99 clusters and found at least one potential route in 65.7% of them. In one case it traced a VRE outbreak to an interventional radiology procedure.
HeapDB is not a replacement for such systems. It is a route-ranking component they could use: it ranks a cluster's shared exposures by timing and learned links, explains each score, and learns from adjudication. The related use case for community outbreaks is outbreak source tracing.
Status and open questions
In design. The example on this page runs today. Your answers to these open questions shape what we build.
- How should severity enter: as a rule, a per-patient leak, or a stratified link table?
- Should a long stay be one event with a duration weight, or one event per day as in the example?
- How should equipment that moves between rooms and units be represented, so that device and location features do not always co-occur?
- What is the right settle window when culture timing depends on when a patient was tested, not when they became colonized?