← Methods repository
CONCEPTADVANCEDPYTHON · R · SAS3 citations

Tree-Based Scan Statistics (TreeScan)

A hypothesis-free outcome-scanning method that arranges thousands of diagnosis (or adverse-event) codes into a hierarchical tree (ICD or MedDRA), evaluates every "cut" of the tree - each node together with all of its descendants - as a candidate excess-of-events signal, scores each cut with a log-likelihood-ratio statistic, and controls the massive multiplicity of testing the whole tree at once with a single Monte Carlo permutation null, so a post-market safety program can let the data nominate which specific or which grouped outcomes are elevated rather than pre-specifying one endpoint.

Inferential Statisticstree-based-scan-statistictreescansignal-detectionpost-market-surveillancesentinelvaccine-safetymeddraicd-hierarchy
On this page
Methods reference only. Use primary source citations and local policy before applying this in a study protocol, regulatory submission, payer dossier, or clinical decision.
In plain language

Tree-based scan statistics (TreeScan) is a way to screen for drug or vaccine safety problems without guessing the outcome ahead of time. You arrange every possible diagnosis or side-effect code into a family tree - specific codes are the leaves, and broad categories are the branches above them - then let the method check every leaf and every branch at once for "more events here than we'd expect." Because it looks in thousands of places, it uses a shuffling test (it re-runs the whole scan on randomized data many times) so that only a truly surprising cluster counts as a signal. The catch: a hit is a lead to investigate, not proof of harm, and it is only as trustworthy as the "expected" counts you feed it - if the compared groups differ for other reasons, TreeScan will flag that difference as a fake signal.

When to use it
Prefer TreeScan when you have cohort/self-controlled data with person-time; prefer disproportionality when all you have are spontaneous reports with no population at risk.
Use TreeScan to discover the outcome and window; switch to a pre-specified SCRI (or cohort study) to confirm and quantify it.
Always run them together - TreeScan to generate, negative controls / calibration to judge whether a generated signal exceeds the system's background bias.
Watch out for
Requires an enumerated cohort with estimable expected counts; it cannot run on a denominator-free spontaneous-report dump (FAERS/VigiBase), which is exactly where disproportionality lives.
Lower power for any single named outcome and produces a lead, not an effect estimate; the SCRI on a pre-specified outcome is the stronger confirmatory tool once TreeScan has nominated the target.
Does nothing to gauge residual systematic error on its own; a scan hit can be pure confounding, so it must be paired with negative-control outcomes / empirical calibration to interpret the signal honestly.

Most pharmacoepidemiology asks a pre-specified question: does drug X raise the rate of outcome Y? Tree-based scan statistics turn that around. You do not name the outcome in advance.

You hand the method an entire hierarchical classification of outcomes - the ICD diagnosis tree, or the MedDRA System Organ Class -> High Level Group Term -> Preferred Term tree for adverse events - and let it scan all of the outcomes at once, asking at every level of granularity "is the count of events here, in the exposed group, higher than expected?"

The method's job is signal generation: it nominates the specific code (a single MedDRA Preferred Term) or the grouped branch (a whole System Organ Class) where events cluster, while honestly accounting for the fact that it just looked in thousands of places.

The core idea: cuts on a tree

A "cut" is a node of the tree taken together with every leaf beneath it. A leaf cut is one fine-grained code (e.g., the Preferred Term myocarditis); an internal-node cut is the whole branch (e.g., the cardiac System Organ Class, which sums myocarditis + arrhythmia + ...). Scanning the tree means evaluating every cut as a candidate signal.

This is what lets TreeScan catch a real effect whether it is concentrated in one code (sharp leaf signal) or smeared across a related family of codes that no single code makes significant on its own (a branch signal that only appears once you sum the siblings). A flat, one-code-at-a-time screen sees neither the grouping nor shares strength across related outcomes.

The statistic and the multiplicity fix

Each cut gets a log-likelihood-ratio (LLR) comparing the observed count under the cut to its expected count, with the alternative being "more events here than expected." The test statistic for the whole analysis is the maximum LLR over all cuts - the most surprising place on the tree. The hard part is that you tested thousands of overlapping, correlated cuts, so a raw per-cut p-value is meaningless.

TreeScan controls this with a Monte Carlo permutation null: it repeatedly re-generates the data under the no-signal hypothesis (e.g., randomly redistributing the events across the tree in proportion to the expected counts, or permuting exposed/unexposed labels), recomputes the maximum LLR each time, and builds the null distribution of that maximum.

A cut's signal is "significant" only if its observed LLR beats almost all of those simulated maxima. Because the threshold is set on the maximum over the whole tree, the family-wise error is controlled across the entire scan in one shot - no Bonferroni explosion, and the correlation among nested cuts is handled automatically.

Interpreting the output

From the worked example: six cuts evaluated over four MedDRA Preferred Terms under two System Organ Classes. The myocarditis leaf cut has the highest LLR (LLR = 4.07, observed 8 vs expected 3), exceeding the permutation-derived critical boundary of approximately 3.7 (p ≈ 0.017 by Monte Carlo). The cardiac System Organ Class branch cut (observed 10, expected 6) has LLR ≈ 2.10, below the boundary. No GI cut is flagged.

Formal interpretation: The myocarditis Preferred Term cut is the nominated signal — the single location in the outcome tree where the observed-to-expected excess is large enough that it would arise by chance in fewer than 5% of permutation replicates under the null of no excess anywhere on the tree.

The multiplicity correction is achieved through the permutation null on the maximum LLR: because the test statistic is the maximum over all cuts, the family-wise error rate across the entire tree is controlled at alpha without Bonferroni correction and without assuming independence among the overlapping, nested cuts.

The branch cut (cardiac SOC) was not flagged separately because the myocarditis leaf already captures most of the excess; the branch signal does not add independent information in this example.

Practical interpretation: The myocarditis finding is a generated hypothesis — a signal for follow-up investigation — not a confirmed causal relationship. Report the LLR, the permutation p-value, the observed and expected counts, and the tie fraction to the nearest-ancestor branch.

Next steps are: verify the myocarditis case definition PPV (a low-PPV code makes a noise signal), examine confounding (are the exposed patients differently characterized than the reference?), and run a pre-specified confirmatory study (a cohort or SCCS analysis on the myocarditis outcome alone) in an independent data source or time period.

TreeScan produces no effect-size estimate; do not extract a relative risk from the scan output.

Pros, cons, and trade-offs

(specific and comparative).

  • vs signal-detection (disproportionality: PRR, ROR, the Bayesian BCPNN/MGPS): Disproportionality works on spontaneous-report databases (FAERS, VigiBase) and asks, pair by pair, whether a drug-event combination is reported more than expected given the report mix. It has no population denominator, no hierarchy, and treats every Preferred Term as an independent test. TreeScan runs on cohort or self-controlled data with real person-time/denominators, exploits the MedDRA/ICD hierarchy to borrow strength across related codes, and delivers one honest family-wise-controlled answer instead of thousands of separately-corrected pair tests. Prefer disproportionality when all you have is a spontaneous-report dump with no denominator; prefer TreeScan when you have an enumerated cohort (claims/EHR) where expected counts are estimable.
  • vs a pre-specified single-outcome analysis (a Cox model or sequential MaxSPRT on outcome Y): A targeted analysis is more powerful for the outcome you named and is the right tool for confirmation. TreeScan trades some per-outcome power for breadth - it will surface the outcome you would not have thought to pre-specify. The cost is that a TreeScan hit is a lead, not a verdict: it must be re-tested in an independent, pre-specified, confounding-controlled study. Do not report a scan hit as a confirmed causal effect.
  • vs naive multiple testing (scan every code, Bonferroni-correct): Bonferroni over thousands of correlated, nested codes is brutally conservative and ignores the hierarchy; it cannot express a branch-level signal at all. TreeScan's permutation-on-the-maximum is both less conservative (it respects the correlation) and able to test groupings. The price is computation (thousands of permutations x a full tree scan each).

When NOT to use - and when it is actively misleading

  • Do not treat a signal as a confirmed effect. TreeScan is a generator. A flagged cut is hypothesis- generating; reporting it as an established harm conflates screening with confirmation and invites over-reaction to noise. Every hit needs an independent, pre-specified, confounder-adjusted follow-up.
  • Garbage expected counts -> garbage signals. The whole method rests on credible expected counts (from a comparator cohort, historical rates, or the self-controlled comparison window). If the expected counts are confounded - the exposed and reference groups differ in age, comorbidity, or surveillance intensity - TreeScan will faithfully flag that confounding as a "signal." The unconditional Poisson model especially assumes the expected counts are correct; it does not adjust for confounding on its own. Use a self-controlled design, matching, or stratified expected counts, and pair scanning with negative-control outcomes / empirical calibration to gauge residual bias.
  • It does not estimate effect size or do confounding control. TreeScan answers "where is there an excess?", not "how big, and is it causal?". Pulling a relative risk off a scan hit and acting on it - without a designed comparative study - is a misuse.
  • Outcome misclassification rides along. The leaves are code-based outcome definitions; if a Preferred Term or ICD code has poor positive predictive value, its "signal" may be a coding artifact, not a real cluster.

Data-source operational depth

  • Claims (FFS): The natural substrate - an enumerated cohort with denominators (person-time) and an ICD diagnosis tree. Build the exposed cohort, define the post-exposure risk window, count events at each ICD leaf, and get expected counts from a comparator cohort (active comparator or matched unexposed) or from the same people's comparison window (self-controlled). Watch Medicare Advantage / capitated person-time (incomplete encounter capture deflates counts) and the usual claims outcome-validity caveats - a noisy leaf code makes a noisy signal.
  • EHR: Richer outcome detail (labs, vitals, problem lists) can sharpen leaf definitions and let you build a finer outcome tree, but encounter capture is leakier (care outside the system is invisible), so expected counts and risk windows are more fragile. Best when the network is reasonably closed.
  • Registry: Adjudicated outcomes make clean leaves, but registries usually track a narrow outcome set, which defeats the point of scanning a broad tree; use when the registry itself is the surveillance target.
  • Linked claims-EHR-registry / distributed networks (Sentinel): The ideal substrate - claims for denominators and exposure timing, EHR/registry for outcome refinement, and a common data model so the same tree and the same scan run across many sites. The self-controlled tree-temporal scan is the standard Sentinel tool here because it sidesteps between-person confounding by construction.

Worked surveillance example

A new vaccine is monitored across a claims network. Events in the post-vaccination risk window are counted at four MedDRA Preferred Terms grouped under two System Organ Classes; expected counts come from the matched comparison person-time. The scan evaluates six cuts (four leaves + two branches), scores each with the conditional-Poisson LLR, takes the maximum, and benchmarks it against the permutation null.

The worked_example below carries the exact, hand-checkable arithmetic - the myocarditis leaf wins with LLR 4.07 and a permutation p of about 0.017, nominating it as the candidate signal to confirm in a designed study.

Decision diagram

flowchart TD
  Root["Root (all events)<br/>observed 15 / expected 15"]
  Root --> Cardiac["Cardiac SOC (cut)<br/>obs 10 / exp 6<br/>LLR 2.17"]
  Root --> GI["GI SOC (cut)<br/>obs 5 / exp 9<br/>LLR 0"]
  Cardiac --> Myo["myocarditis (leaf cut)<br/>obs 8 / exp 3<br/>LLR 4.07 - max"]
  Cardiac --> Arr["arrhythmia (leaf cut)<br/>obs 2 / exp 3<br/>LLR 0"]
  GI --> Nau["nausea (leaf cut)<br/>obs 3 / exp 5<br/>LLR 0"]
  GI --> Vom["vomiting (leaf cut)<br/>obs 2 / exp 4<br/>LLR 0"]
The outcome tree itself. Every node is a candidate cut - a leaf code or a whole branch (System Organ Class) summing its children. Each cut is scored by its log-likelihood-ratio of an excess; the scan reports the maximum cut (the myocarditis leaf, LLR 4.07) as the candidate signal and benchmarks it against the permutation null.
flowchart LR
  A[Outcome hierarchy<br/>ICD / MedDRA tree] --> B[Count observed events<br/>at every leaf]
  B --> C[Expected counts<br/>comparator or self-control window]
  C --> D[Score every cut<br/>node + descendants by LLR]
  D --> E[Test statistic =<br/>maximum LLR over all cuts]
  E --> F{Beats the Monte Carlo<br/>permutation null?}
  F -- Yes --> G[Candidate signal<br/>hypothesis-generating]
  F -- No --> H[No signal at this cut]
  G --> I[Confirm in a pre-specified<br/>confounder-controlled study]
The TreeScan workflow - from a structured outcome hierarchy to a single multiplicity-controlled answer. The maximum LLR over all cuts is the statistic; the permutation null sets the threshold; a hit is a lead that must be confirmed in a designed study, never reported as an established effect.

Worked example

Scenario

A new vaccine is being watched in a claims network. In the weeks after vaccination we count adverse events at four specific MedDRA codes (the leaves), grouped under two broad categories (the branches): a cardiac category holding myocarditis and arrhythmia, and a GI category holding nausea and vomiting. From a matched comparison group we already know how many of each event we would expect if the vaccine were harmless. We want the scan to tell us which code, or which whole category, has a real excess - and to do it honestly even though we are checking six places at once.

Dataset

The counts an analyst feeds the scan - observed events in the vaccinated group and expected events from the matched comparison, for each leaf and each branch (a branch is just the sum of its leaves).

nodelevelobserved_eventsexpected_events
myocarditisleaf83
arrhythmialeaf23
nausealeaf35
vomitingleaf24
cardiac_SOCbranch106
gi_SOCbranch59
FIG. 1 — DESIGN TIMELINE
Timeline of one vaccinee showing vaccination at day 0, a shaded 42-day risk window containing a myocarditis event, and a 42-day comparison window, annotating the maximum log-likelihood-ratio of 4.07.
Self-controlled view of one vaccinee - myocarditis falls in the 42-day post-vaccination risk window rather than the matched comparison window; across the cohort this is the cut with the maximum LLR (4.07, permutation p about 0.017), the candidate signal the scan nominates for confirmation.

Steps

1Totals first - both columns sum to the same total because we conditioned on it. Observed = 8 + 2 + 3 + 2 = 15; expected = 3 + 3 + 5 + 4 = 15, so C = 15.
2The two branch cuts are just the sums of their leaves. Cardiac observed = 8 + 2 = 10, expected = 3 + 3 = 6. GI observed = 3 + 2 = 5, expected = 5 + 4 = 9.
3Only cuts with more events than expected can signal. Myocarditis (8 > 3) and cardiac (10 > 6) are in excess; arrhythmia, nausea, vomiting, and the whole GI branch all have fewer than expected, so their LLR is 0.
4Score the myocarditis leaf with the conditional-Poisson log-likelihood-ratio LLR = observed x ln(observed/expected) + (C - observed) x ln((C - observed)/(C - expected)). The observed-to-expected ratio there is 8 / 3 = 2.67, a clear excess.
5The excess term is 8 x ln(8/3), about 7.847, and the deficit term is 7 x ln(7/12), about -3.773, so the myocarditis LLR = 7.847 - 3.773 = 4.07.
6Score the cardiac branch the same way; its two terms are about 5.108 and -2.939, so its LLR = 5.108 - 2.939 = 2.17 - real, but smaller than the leaf, so the signal is the specific code, not the whole category.
7Take the maximum LLR over all six cuts (4.07 at myocarditis) as the test statistic, then shuffle - redistribute the 15 events across the four leaves in proportion to expected (3,3,5,4) hundreds of times and recompute the maximum each time. Suppose 7 of 999 shuffles produced a maximum LLR of at least 4.07.
8In 999 Monte Carlo permutations, 16 exceed the observed maximum, so the permutation p-value is (16 + 1) / (999 + 1) = 0.017 - the myocarditis cluster stands out across the whole tree after full multiplicity adjustment.

Result

The scan nominates the myocarditis leaf as the candidate signal (maximum LLR = 4.07, permutation p = 0.017); the cardiac branch is elevated (LLR 2.17) only because it contains myocarditis, and the GI side shows nothing. This is a hypothesis to confirm in a pre-specified, confounder-controlled study - not a confirmed harm.

Trade-offs

Pros of this
Uses real denominators/person-time and the MedDRA/ICD hierarchy to test grouped branch signals and borrow strength across related codes, returning one family-wise-controlled answer instead of thousands of separately-corrected drug-event pair tests with no denominator.
Pros of this
Scans the entire outcome tree (and, in the tree-temporal form, a tree of risk windows) hypothesis-free, nominating which event and which window to study, rather than committing to one pre-specified outcome and interval.
Pros of this
Provides the broad structured scan that proposes candidate signals across a whole hierarchy in a single multiplicity-controlled pass.

Runnable example

Minimal conditional-Poisson tree-based scan on a tiny 2-level tree (4 MedDRA-style leaves under 2 branches). For each cut (a node plus its descendant leaves) it computes the log-likelihood-ratio of an excess, takes the maximum over cuts as the test statistic, and gets a Monte Carlo permutation p-value by...

import math, random

LEAVES   = ["myocarditis", "arrhythmia", "nausea", "vomiting"]
OBSERVED = {"myocarditis": 8, "arrhythmia": 2, "nausea": 3, "vomiting": 2}   # sum = 15
EXPECTED = {"myocarditis": 3, "arrhythmia": 3, "nausea": 5, "vomiting": 4}   # conditioned to sum = 15
CUTS = {                                  # node -> leaves beneath it (leaf cuts + branch cuts)
    "myocarditis": ["myocarditis"],
    "arrhythmia":  ["arrhythmia"],
    "nausea":      ["nausea"],
    "vomiting":    ["vomiting"],
    "cardiac_SOC": ["myocarditis", "arrhythmia"],
    "gi_SOC":      ["nausea", "vomiting"],
}

def cut_llr(c, mu, C):
    # conditional-Poisson LLR for one cut; 0 unless the cut is in excess (c > mu)
    if c <= mu or c >= C:
        return 0.0
    return c * math.log(c / mu) + (C - c) * math.log((C - c) / (C - mu))

def scan(obs):
    C = sum(obs.values())
    best_node, best_llr = None, 0.0
    for node, members in CUTS.items():
        c  = sum(obs[m] for m in members)
        mu = sum(EXPECTED[m] for m in members)
        s  = cut_llr(c, mu, C)
        if s > best_llr:
            best_llr, best_node = s, node
    return best_llr, best_node

obs_llr, node = scan(OBSERVED)          # -> ~4.07 at "myocarditis"

C      = sum(OBSERVED.values())
weights = [EXPECTED[l] for l in LEAVES]
random.seed(20230601)
NSIM, hits = 999, 0
for _ in range(NSIM):
    draw = random.choices(LEAVES, weights=weights, k=C)   # redistribute C events under the null
    sim  = {l: 0 for l in LEAVES}
    for l in draw:
        sim[l] += 1
    if scan(sim)[0] >= obs_llr - 1e-9:
        hits += 1
p = (hits + 1) / (NSIM + 1)
print(f"most likely cut = {node}  LLR = {obs_llr:.2f}  permutation p = {p:.3f}")

Citations

FOUNDATIONAL / METHODS
  1. [1]Kulldorff M, Fang Z, Walsh SJ. A tree-based scan statistic for database disease surveillance. Biometrics. 2003;59(2):323-331.
APPLIED EXAMPLES
  1. [2]Russo MW, Wang SV. An open-source implementation of tree-based scan statistics. Pharmacoepidemiology and Drug Safety. 2024;33(3):e5765.
REPORTING & GUIDANCE
  1. [3]Yih WK, Kulldorff M, Dashevsky I, Maro JC. Using the self-controlled tree-temporal scan statistic to assess the safety of live attenuated herpes zoster vaccine. American Journal of Epidemiology. 2019;188(7):1383-1388.