Outbreak source tracing
Which food that cases bought before falling ill most likely explains a cluster of foodborne illness?
Summary
We give outbreak investigators a ranked, explained list of suspect foods, per case and for the whole cluster, built from shopper-card purchase records. Every new case re-ranks the list. Shoppers who bought a product and stayed well count against it, just as controls do in a case-control study.
Investigators ask this when a lab or surveillance system links a set of illnesses and a retailer agrees to share purchase records for the cases. Today they compare case baskets by hand or in spreadsheets, and staples like milk and bread top every naive count. With HeapDB, they get a standing query that keeps the shortlist current, weighs timing by the pathogen's incubation period, and shows why each food ranks where it does.
Model
| HeapDB | Outbreak source tracing |
|---|---|
| Entity | A card holder: a case patient, or a shopper who did not fall ill (a control). |
| Events | Line items from shopper-card purchase records (type purchase), plus restaurant visits (type restaurant). One event per product bought. |
| Features | product:<id> (a GTIN-like product or lot), category:<x>, brand:<x>, store:<id>; restaurant:<id> for visits. |
| Weight (dose) | 1.0 per line item in the example. Quantity or package size could be used where it is recorded. |
| Effects and kernels | illness (illness onset), LogNormalKernel(median=1.5 * D, sigma=0.5, tail_sigmas=3.0): a Salmonella-like incubation, truncated near 6.7 days. Registered with repeatable=False, since each case is counted once. |
| Leak | leak=0.5: onsets not explained by any logged purchase (sporadic illness, cash purchases, food from elsewhere). |
| Rules | None in the example. A rule could down-weight non-food items (category:household) or block products sold only after the onset. |
Learning
- Confirms (
feedback): a lab or traceback finding for a case, such as a matching isolate from a leftover package, confirms that case's purchase of the vehicle. In the example 8 of the cases get this. - Observed without labels (
observe): every other case's onset. Each purchase in the incubation window gets a soft share of credit in proportion to its current score; the leak keeps a share for "none of these". - Settles (
settle): purchases whose incubation window passed with no onset for that shopper get negative evidence. "The window passed" means the purchase is older than the kernel's support (about 6.7 days here). For the standing query,settle(..., partial=True)adds evidence in proportion to the share of incubation mass already elapsed; a later onset undoes it. - Priors: the default Beta(1, 4) for every feature.
global_pseudocount=1e6makes each case borrow the full population's evidence, so per-case rankings reflect the cluster, not one person's history.
Sidecar architecture
The system of record stays with its owners: the retailer's loyalty and point-of-sale data, and the health department's case surveillance system. Under a data-sharing agreement, a scoped extract of card holders' purchases (cases, and a comparison set of other shoppers) reaches HeapDB as a batch or stream; case onsets arrive from the case-report system as they are entered. HeapDB writes back only the ranked suspect list and its explanations, for the investigation file. It holds no data of its own: it can be rebuilt by replaying the extract and the case list, and the extract can be deleted when the investigation closes.
Worked example
In the simulation, 1200 card holders shop at four stores for 28 days. A bagged spring mix, bought by a minority of shoppers, is contaminated; milk, bread and bananas are in most baskets; ranch dressing is often bought on the same trip as the salad but is innocent.
Output of 0001-outbreak-source-tracing.py
SCENARIO
1200 card holders, 28 days, 53,610 logged purchases (cash trips missing), 1784 restaurant visits
100 illness cases; 247 shoppers have a logged spring-mix purchase; 8 cases confirmed by traceback
cases with a logged purchase in the 7 days before onset: 2210-spring-mix 74, 1001-milk-1gal 69, 1002-bread-white 67, 1003-bananas 70
STANDING QUERY: SUSPECT FOODS AS CASES ARRIVE
5 cases, day 3.3: spring-mix #23 top 3: 4019-item, 4005-item, 4059-item
15 cases, day 5.9: spring-mix #1 top 3: 2210-spring-mix, 4005-item, 4019-item
30 cases, day 9.0: spring-mix #1 top 3: 2210-spring-mix, 4019-item, 4005-item
100 cases, day 28.0: spring-mix #1 top 3: 2210-spring-mix, 4019-item, 4047-item
ONE CASE, EXPLAINED (shopper-0060, onset day 26.6)
Top causes of 'illness' for shopper-0060 at t=2026-08-29T15:28:02Z:
1. #50955 purchase@2026-08-28T17:03:41Z [brand:freshvale,category:leafy-greens,product:2210-spring-mix,store:01] w=1 score=0.0262
lag 22.41h -> kernel 0.904 x weight 1 x link 0.029 [noisy-OR of brand:freshvale+product:2210-spring-mix=0.03 inseparable, category:leafy-greens=0.00 (a=2.94, b=1441.83), store:01=0.00 (a=1.42, b=12286.39)]
2. #50956 purchase@2026-08-28T17:03:42Z [brand:pantryco,category:condiments,product:3105-ranch-dressing,store:01] w=1 score=0.0061
lag 22.41h -> kernel 0.904 x weight 1 x link 0.007 [noisy-OR of brand:pantryco+category:condiments+product:3105-ranch-dressing=0.01 inseparable, store:01=0.00 (a=1.42, b=12286.39)]
warning: 'brand:pantryco', 'category:condiments', 'product:3105-ranch-dressing' always co-occur (2 events): feedback cannot separate their effects yet; reported as joint cause brand:pantryco+category:condiments+product:3105-ranch-dressing
3. #50957 purchase@2026-08-28T17:03:43Z [brand:none,category:leafy-greens,product:2212-romaine-head,store:01] w=1 score=0.0040
lag 22.41h -> kernel 0.904 x weight 1 x link 0.004 [noisy-OR of category:leafy-greens=0.00 (a=2.94, b=1441.83), product:2212-romaine-head=0.00 (a=1.04, b=523.04), brand:none=0.00 (a=1.11, b=4456.32), store:01=0.00 (a=1.42, b=12286.39)]
[segments: 1 scanned / 2 pruned / 3 in window; events scored: 5]
ACROSS ALL CASES (product features, global links)
Links of 'illness' for (global) (baseline 0.002; associations under the model, not causal effects):
feature mean 90% CI lift n alpha beta
product:2210-spring-mix 0.023 [0.012, 0.036] 10.15 381.4 8.80 377.59
product:4019-item 0.002 [0.000, 0.007] 1.09 460.2 1.14 464.03
product:4047-item 0.002 [0.000, 0.007] 1.04 478.2 1.13 482.10
product:4041-item 0.002 [0.000, 0.007] 1.02 484.2 1.12 488.10
product:4059-item 0.002 [0.000, 0.007] 1.02 484.2 1.12 488.12
product:4031-item 0.002 [0.000, 0.007] 1.02 484.3 1.11 488.15
product:4046-item 0.002 [0.000, 0.007] 1.00 482.2 1.10 486.12
product:3105-ranch-dressing 0.002 [0.000, 0.006] 0.98 658.4 1.46 661.94
CHECK
seeded cause spring-mix ranks #1 of 69 products (lift 10.15); near-miss ranch-dressing #8 (lift 0.98); milk #69
spring-mix purchase is the top cause for 71/74 cases with a logged spring-mix purchase in the windowShow the full script (245 lines)
"""Outbreak source tracing: which food explains a cluster of illness cases?
Simulates four weeks of shopper-card purchases at four grocery stores (plus a
few restaurant visits) for 1,200 card holders, and the illness onsets that
follow. Investigators add cases one by one. HeapDB ranks suspect foods per
case (top_causes) and across the cluster (link_report).
Hidden ground truth (the seeded cause):
* product:2210-spring-mix, a bagged salad, is contaminated. Each purchase
makes the buyer ill with probability 0.15. Onset = days until eaten
(0-1 d) + a log-normal incubation (median 1.5 d, sigma 0.5).
* Only a minority of shoppers buy it. Milk, bread and bananas are in most
baskets, so most cases bought them too. They are innocent.
* Near-miss: product:3105-ranch-dressing is bought on 60% of salad trips. It
is innocent but rides along with the salad.
* Noise: 2% of shoppers get sick for unrelated reasons, and 10% of trips are
paid in cash, so they never reach the purchase log.
Learning: every case is observe()d (onset, no known cause); 8 cases get a
traceback confirmation via feedback(); shoppers whose purchases pass the
incubation window without illness are settle()d (negative evidence).
Run:
pip install heapdb
python 0001-outbreak-source-tracing.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 = 7
N_SHOPPERS = 1200
DAYS = 28
T0 = 1785715200.0 # 2026-08-03T00:00:00Z; the simulation runs in seconds after T0
STORES = ["store:01", "store:02", "store:03", "store:04"]
# product id -> (category, brand, probability per trip for a typical shopper)
STAPLES = {
"product:1001-milk-1gal": ("category:dairy", "brand:hillcrest", 0.55),
"product:1002-bread-white": ("category:bakery", "brand:oakmill", 0.45),
"product:1003-bananas": ("category:produce", "brand:none", 0.45),
"product:1004-eggs-12": ("category:dairy", "brand:hillcrest", 0.30),
"product:1005-chicken-breast": ("category:meat", "brand:riverfarm", 0.25),
}
SALAD = "product:2210-spring-mix"
DRESSING = "product:3105-ranch-dressing"
SPECIAL = {
SALAD: ("category:leafy-greens", "brand:freshvale"),
DRESSING: ("category:condiments", "brand:pantryco"),
"product:2211-kale-bag": ("category:leafy-greens", "brand:freshvale"),
"product:2212-romaine-head": ("category:leafy-greens", "brand:none"),
}
OTHER = [f"product:{4000 + i}-item" for i in range(60)]
OTHER_CAT = ["category:pantry", "category:frozen", "category:snacks", "category:household"]
RESTAURANTS = [f"restaurant:r{i:02d}" for i in range(12)]
ATTACK = 0.15 # P(ill | one purchase of the contaminated salad)
SPORADIC = 0.02 # P(ill for unrelated reasons) per shopper
CASH = 0.10 # share of trips paid in cash: never logged
N_CONFIRMED = 8 # cases with a traceback / lab confirmation
def incubation(rng: random.Random) -> float:
eat_delay = rng.uniform(0, 1) * D
return eat_delay + math.exp(rng.gauss(math.log(1.5 * D), 0.5))
def simulate(rng: random.Random):
"""Return (logged events, onsets, true cause event key per case)."""
events, onsets = [], {}
for s in range(N_SHOPPERS):
who = f"shopper-{s:04d}"
salad_fan = rng.random() < 0.15
home = rng.choice(STORES)
trips = sorted(rng.uniform(0, DAYS) * D for _ in range(rng.randint(5, 12)))
first_ill = math.inf
for t in trips:
t += rng.uniform(8, 20) * H - (t % D)
store = home if rng.random() < 0.8 else rng.choice(STORES)
basket = [p for p, (_, _, q) in STAPLES.items() if rng.random() < q]
bought_salad = rng.random() < (0.35 if salad_fan else 0.01)
if bought_salad:
basket.append(SALAD)
if rng.random() < (0.60 if bought_salad else 0.05):
basket.append(DRESSING)
for p in ("product:2211-kale-bag", "product:2212-romaine-head"):
if rng.random() < 0.06:
basket.append(p)
basket += rng.sample(OTHER, rng.randint(2, 5))
cash = rng.random() < CASH
for i, p in enumerate(basket):
if p in STAPLES:
cat, brand, _ = STAPLES[p]
elif p in SPECIAL:
cat, brand = SPECIAL[p]
else:
n = int(p.split(":")[1].split("-")[0])
cat, brand = OTHER_CAT[n % 4], f"brand:b{n % 9}"
if not cash:
events.append((t + i, who, "purchase", {p, cat, brand, store}))
if p == SALAD and rng.random() < ATTACK:
first_ill = min(first_ill, t + incubation(rng))
for _ in range(rng.randint(0, 3)):
t = rng.uniform(0, DAYS) * D
events.append((t, who, "restaurant", {rng.choice(RESTAURANTS)}))
if rng.random() < SPORADIC:
first_ill = min(first_ill, rng.uniform(2, DAYS) * D)
if first_ill < DAYS * D:
onsets[who] = first_ill
events.sort(key=lambda e: e[0])
return events, onsets
def make_db() -> HeapDB:
db = HeapDB(bucket_seconds=12 * H)
# Purchase-to-onset lag: Salmonella-like incubation, median 1.5 d. Truncated
# at median * exp(3 sigma) (about 6.7 d): later purchases cannot explain it.
# Each case falls ill once (repeatable=False). A large global_pseudocount lets
# every case borrow the whole population's evidence: the case-control view.
db.register_effect("illness", LogNormalKernel(median=1.5 * D, sigma=0.5, tail_sigmas=3.0),
leak=0.5, repeatable=False, cause_types={"purchase", "restaurant"},
global_pseudocount=1e6)
return db
PRODUCTS = list(STAPLES) + list(SPECIAL) + OTHER
def product_ranking(db: HeapDB, min_support: float = 20):
"""Cluster-level answer: product features ranked by learned link strength."""
return db.link_report("illness", features=PRODUCTS, min_support=min_support)
def short(feature: str) -> str:
return feature.split(":", 1)[1]
def main() -> None:
rng = random.Random(SEED)
events, onsets = simulate(rng)
db = make_db()
stream = [(ev[0], 0, ev) for ev in events]
stream += [(t, 1, who) for who, t in onsets.items()]
stream.sort(key=lambda x: (x[0], x[1]))
by_shopper: dict[str, list] = {}
confirmed, n_cases, day, snapshots = [], 0, 0, []
checkpoints = {5, 15, 30}
for t, kind, item in stream:
while t >= (day + 1) * D: # daily: settle windows that have passed
day += 1
db.settle(T0 + day * D)
if kind == 0:
_, who, typ, feats = item
ev = db.insert(who, T0 + t, typ, feats)
by_shopper.setdefault(who, []).append(ev)
continue
who = item
db.observe(who, "illness", T0 + t)
n_cases += 1
salad_buys = [e for e in by_shopper.get(who, [])
if SALAD in e.features and 0 < T0 + t - e.time < 6 * D]
if salad_buys and len(confirmed) < N_CONFIRMED and n_cases % 3 == 0:
db.feedback(who, "illness", T0 + t, confirmed_event_id=salad_buys[-1].id)
confirmed.append(who)
if n_cases in checkpoints:
# The standing query: re-rank suspect foods now that a case was added.
# partial=True: windows still open get negative evidence in proportion
# to the kernel mass already elapsed; a later onset undoes it.
db.settle(T0 + t, partial=True)
rep = product_ranking(db, min_support=3)
rank = [r.feature for r in rep].index(SALAD) + 1 if rep.row(SALAD) else "-"
snapshots.append((n_cases, t, rank, [short(r.feature) for r in list(rep)[:3]]))
db.settle(T0 + DAYS * D + 8 * D)
rep = product_ranking(db)
snapshots.append((n_cases, DAYS * D, [r.feature for r in rep].index(SALAD) + 1,
[short(r.feature) for r in list(rep)[:3]]))
n_purch = sum(1 for e in events if e[2] == "purchase")
buyers = {e[1] for e in events if SALAD in e[3]}
print("SCENARIO")
print(f" {N_SHOPPERS} card holders, {DAYS} days, {n_purch:,} logged purchases "
f"(cash trips missing), {len(events) - n_purch} restaurant visits")
print(f" {n_cases} illness cases; {len(buyers)} shoppers have a logged spring-mix purchase; "
f"{len(confirmed)} cases confirmed by traceback")
share = []
for p in (SALAD, "product:1001-milk-1gal", "product:1002-bread-white", "product:1003-bananas"):
n = sum(any(p in e.features and 0 < T0 + t - e.time < 7 * D for e in by_shopper.get(w, []))
for w, t in onsets.items())
share.append(f"{short(p)} {n}")
print(f" cases with a logged purchase in the 7 days before onset: {', '.join(share)}")
print()
print("STANDING QUERY: SUSPECT FOODS AS CASES ARRIVE")
for n, t, rank, top in snapshots:
print(f" {n:>3} cases, day {t / D:4.1f}: spring-mix #{rank:<3} top 3: {', '.join(top)}")
# One case, explained: a case without a lab confirmation who bought the
# salad and the staples in the window.
pick = None
for who in sorted(onsets):
if who in confirmed:
continue
w = [e for e in by_shopper.get(who, []) if 0 < T0 + onsets[who] - e.time < 6 * D]
fs = [f for e in w for f in e.features]
if fs.count(SALAD) == 1 and {DRESSING, "product:1001-milk-1gal"} <= set(fs):
pick = who
break
print()
print(f"ONE CASE, EXPLAINED ({pick}, onset day {onsets[pick] / D:.1f})")
print(format_result(db.top_causes(pick, "illness", T0 + onsets[pick], k=3, explain=True)))
print()
print("ACROSS ALL CASES (product features, global links)")
print("\n".join(str(rep).splitlines()[:10])) # header + top 8 rows
# Per-case accuracy: is a spring-mix purchase the top cause?
hit = tot = 0
for who, t in onsets.items():
if any(SALAD in e.features and 0 < T0 + t - e.time < 6 * D for e in by_shopper.get(who, [])):
tot += 1
top = db.top_causes(who, "illness", T0 + t, k=1).top
hit += top is not None and SALAD in top.event.features
feats = [r.feature for r in rep]
salad_rank, dress_rank = feats.index(SALAD) + 1, feats.index(DRESSING) + 1
milk_rank = feats.index("product:1001-milk-1gal") + 1
print()
print("CHECK")
print(f" seeded cause spring-mix ranks #{salad_rank} of {len(feats)} products "
f"(lift {rep.row(SALAD).lift:.2f}); near-miss ranch-dressing #{dress_rank} "
f"(lift {rep.row(DRESSING).lift:.2f}); milk #{milk_rank}")
print(f" spring-mix purchase is the top cause for {hit}/{tot} cases with a logged "
f"spring-mix purchase in the window")
if salad_rank != 1 or dress_rank == 1:
raise SystemExit(1)
if __name__ == "__main__":
main()- Naive counts do not separate the vehicle: of the cases, 74 bought spring mix in the week before onset, but 69 bought milk and 70 bought bananas. The many well shoppers who bought milk are what push it to #69 of 69 products.
- The standing query is noisy at first (spring mix #23 after 5 cases, as unrelated items lead) and settles on spring mix from 15 cases on.
- Across all cases, spring mix leads with mean link 0.023 and a 90% credible interval of [0.012, 0.036], lift 10.15. The next products sit at lift 1.09 and below, with intervals starting at 0.000.
- The near-miss, ranch dressing, ends at #8 with lift 0.98. Each line item is its own event, so the salad and dressing compete for each case's credit, and the salad's stronger link wins; shoppers who bought dressing without salad and stayed well add the rest. Had the two been recorded as one event, they would share credit, and while they always co-occurred for a case HeapDB would report them as one inseparable joint cause, as it does for
brand:freshvale+product:2210-spring-mixin the explained case. - Per case, a spring-mix purchase is the top cause for 71/74 cases with a logged spring-mix purchase in the window.
Run it:
pip install heapdb
python 0001-outbreak-source-tracing.py # copy the script from "Show the full script" aboveEvaluation
- Metric: the rank of the confirmed vehicle in the cluster-level
link_report, as a function of the number of cases entered. Secondary: per-case top-1 accuracy against cases with a confirmed exposure. - Baseline: the share of cases who bought each product (the naive count), and a standard case-control odds ratio per product computed on the same extract. HeapDB should match the odds ratio's ranking and reach it with fewer cases, because it uses incubation timing.
- Ground truth: closed past investigations where the vehicle was confirmed by laboratory matching and product traceback. Synthetic outbreaks seeded into real, de-identified purchase data are a second source.
Limits and risks
- Association, not proof. The ranking is an association under the model. A product becomes a confirmed vehicle only through laboratory testing and traceback by investigators.
- Missing data. Cash purchases, other retailers, home gardens and shared meals never reach the log. Case recall is also imperfect. The leak absorbs some of this, but a vehicle bought mostly with cash can be missed.
- Confounding. Products bought together, from the same supplier, or by the same kind of shopper can share credit. Separate events help, but they do not remove the problem.
- Control selection. Who counts as a control changes the answer. A comparison set drawn from different stores or periods than the cases can bias the ranking.
- Consent and privacy. Purchase records are personal data. Access needs consent from cases or a legal basis, a narrow extract, and deletion when the investigation closes.
- Human decisions. Recalls, public warnings and closures are regulatory decisions made by investigators and food-safety agencies, not by HeapDB.
Prior art
Investigators already use shopper-card records. In a ground-beef E. coli O26 cluster, purchase records from shopper cards supplied dates, brands and product types used to find a common exposure. CDC traced a 2010 national Salmonella Montevideo outbreak to pepper used on salami with the help of shopper cards.
The kernel follows long-standing epidemiology. Incubation periods are right-skewed and roughly log-normal, as described since Sartwell in 1950 and revisited by Ottino-Löffler, Scott and Strogatz.
HeapDB does not replace interviews, case-control analysis or traceback. It complements them with a ranking that updates as cases arrive and explains each score. The single-person version of the same idea is the personal health food-trigger demo.
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 controls be chosen and weighted: all non-case shoppers, matched by store and week, or a random sample?
- Should one event be a line item or a whole basket? Line items separate co-purchases; baskets match how some retailers share data.
- How should the kernel handle pathogens with long or uncertain incubation, such as Listeria, where the window spans weeks?
- What is the smallest extract (fields, time range, shoppers) that still gives a useful ranking?