Comparing Scan Statistics and Local Moran’s I
This guide, part of Cluster Detection Power & Benchmarking, puts the two most widely used local cluster methods against each other on identical synthetic outbreaks so the choice between them rests on evidence rather than habit.
Problem Context & Constraints
Health departments usually run one of two local methods. The spatial scan statistic searches over many candidate windows — typically circles of increasing radius around each centroid — and reports the window with the highest likelihood ratio, tested by Monte Carlo replication of the whole search. Local Moran’s I computes, for every area, how strongly its value co-varies with its neighbours’ values, tested by conditional permutation, and labels each area high-high, low-low, high-low or low-high.
They are often presented as interchangeable, and they are not. The scan statistic asks is there a region where risk is elevated, and where is the most likely one? It controls the family-wise error across all windows by construction, and it returns a small number of clusters with a single p-value each. Local Moran’s I asks which areas resemble their neighbours unusually strongly? It returns a label for every area, each with its own p-value, and without correction for many tests it flags areas by chance in proportion to the number of areas.
Comparing them fairly takes three commitments. Use the same outbreaks and the same null data for both, so differences reflect the methods rather than the inputs. Score on more than detection — location accuracy and false alarm rate matter as much. And feed each method the input it expects: the scan statistic works on counts and population, while Local Moran’s I works on a rate, and it should be a smoothed rate, since raw rates in small areas swamp the statistic with noise.
Prerequisites
python3.11numpy1.26.4,pandas2.2.2,geopandas0.14.4libpysal4.10,esda2.5.1 for Local Moran’s I- A scan implementation: SaTScan 10.1 driven by parameter files, or a Python Poisson circular scan for fast benchmarking loops
- A scenario library of planted outbreaks, as built in Simulating Outbreaks to Measure Detection Power
Step-by-Step Solution
Wrap each method behind the same interface — take counts and population, return the set of flagged areas and a signal decision — then score both against the truth for every replicate.
# Head-to-head benchmark: Poisson circular scan vs Local Moran's I.
# Pinned: numpy==1.26.4, pandas==2.2.2, geopandas==0.14.4, libpysal==4.10, esda==2.5.1
import logging
import numpy as np
import pandas as pd
from libpysal.weights import Queen
from esda.moran import Moran_Local
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("scan_vs_lisa")
ALPHA = 0.05
def eb_rate(cases: np.ndarray, pop: np.ndarray) -> np.ndarray:
"""Global empirical Bayes smoothed rate, the input Local Moran's I needs."""
r = cases / pop
m = cases.sum() / pop.sum()
v = max((pop * (r - m) ** 2).sum() / pop.sum() - m / pop.mean(), 0.0)
shrink = v / (v + m / pop)
return m + shrink * (r - m)
def lisa_flags(cases, pop, w, perms: int = 999, seed: int = 1) -> dict:
"""High-high areas from Local Moran's I, optionally FDR-corrected."""
lm = Moran_Local(eb_rate(cases, pop), w, permutations=perms, seed=seed)
p = lm.p_sim
order = np.argsort(p)
thresh = ALPHA * np.arange(1, len(p) + 1) / len(p)
passed = np.zeros(len(p), dtype=bool)
k = np.where(p[order] <= thresh)[0]
if k.size:
passed[order[: k.max() + 1]] = True
hh = (lm.q == 1) & passed
return {"flagged": set(np.flatnonzero(hh)), "signal": bool(hh.any())}
def scan_flags(cases, pop, centroids, max_frac: float = 0.25,
reps: int = 999, seed: int = 1) -> dict:
"""Poisson circular scan: most likely cluster and its Monte Carlo p-value."""
rng = np.random.default_rng(seed)
d = np.linalg.norm(centroids[:, None, :] - centroids[None, :, :], axis=2)
order = np.argsort(d, axis=1)
total_c, total_p = cases.sum(), pop.sum()
def best(c):
top, window = 0.0, None
for i in range(len(c)):
cc = np.cumsum(c[order[i]])
pp = np.cumsum(pop[order[i]])
ok = pp <= max_frac * total_p
e = total_c * pp / total_p
with np.errstate(divide="ignore", invalid="ignore"):
llr = np.where((cc > e) & ok,
cc * np.log(cc / e) + (total_c - cc) * np.log((total_c - cc) / (total_c - e)),
0.0)
j = int(np.nanargmax(llr))
if llr[j] > top:
top, window = float(llr[j]), order[i][: j + 1]
return top, window
obs, window = best(cases)
null = [best(rng.multinomial(total_c, pop / total_p))[0] for _ in range(reps)]
p = (1 + sum(n >= obs for n in null)) / (reps + 1)
flagged = set(window.tolist()) if (window is not None and p <= ALPHA) else set()
return {"flagged": flagged, "signal": p <= ALPHA, "p": p}
def score(result: dict, truth: set) -> dict:
"""Detection, location precision and recall against the planted areas."""
f = result["flagged"]
tp = len(f & truth)
return {
"signal": result["signal"],
"hit": tp > 0,
"precision": tp / len(f) if f else np.nan,
"recall": tp / len(truth) if truth else np.nan,
"false_areas": len(f - truth),
}
def compare(areas, scenarios, n_reps: int = 200) -> pd.DataFrame:
"""Run both methods on identical simulated data and collect scores."""
w = Queen.from_dataframe(areas, use_index=False)
w.transform = "r"
xy = np.column_stack([areas.geometry.centroid.x, areas.geometry.centroid.y])
pop = areas["population"].to_numpy(dtype=float)
rows = []
for sc in scenarios:
rng = np.random.default_rng(sc["seed"])
for r in range(n_reps):
lam = pop * sc["rr"]
cases = rng.multinomial(sc["total"], lam / lam.sum())
for name, res in (("scan", scan_flags(cases, pop, xy, seed=r)),
("lisa", lisa_flags(cases, pop, w, seed=r))):
rows.append({"scenario": sc["name"], "method": name, "rep": r,
**score(res, sc["truth"])})
out = pd.DataFrame(rows)
log.info("scored %d method-replicates across %d scenarios", len(out), len(scenarios))
return out
The null scenarios — where rr is all ones and truth is empty — are essential. They give each method’s false alarm rate, and a method that signals on 30 percent of null replicates has not earned its power on the outbreak scenarios.
The scan’s Monte Carlo loop dominates run time. For a benchmark of several hundred replicates across a dozen scenarios, run it with fewer replications (199) during development and the full 999 only for the reported results; the ranking of methods rarely changes, but the p-value resolution does.
Validation & Edge Cases
Unfair inputs. Running Local Moran’s I on raw rates while the scan uses counts and population hands the scan an advantage it did not earn: small-population areas with one or two cases dominate raw-rate LISA. Smooth the rate first, as the code does, and state it.
Different definitions of detection. The scan signals once per map; Local Moran’s I signals if any area is high-high and significant. Without FDR correction the second definition fires on almost every null map of a few hundred areas, which makes its power meaningless. Apply the correction — see Applying FDR Correction to Local Moran’s I Results — or compare at matched false alarm rates.
Weights choice. Local Moran’s I results depend on the neighbour definition; queen contiguity, k-nearest neighbours and distance bands give different power, especially on irregular geographies. Benchmark at least two weight specifications and report the spread.
Scan window limits. A maximum window of 50 percent of population, the SaTScan default, lets the scan return huge windows that swallow the outbreak and half the state. For surveillance, 10 to 25 percent is usually more useful, and the limit must be recorded alongside the result.
Location scoring for tiny outbreaks. When the planted outbreak is a single area, precision and recall are coarse — either 0 or 1 for recall. Score location by distance from the flagged centroid to the true centre as well, so near misses count differently from far misses.
Replicate agreement. The same seed feeding both methods is what makes the comparison paired. Analyse the results as paired: count replicates where one method detects and the other does not, and test that difference directly rather than comparing two independent power estimates with overlapping intervals.
INFO scan_vs_lisa scored 4800 method-replicates across 12 scenarios
INFO scan_vs_lisa paired discordance, corridor scenarios: lisa-only 211, scan-only 58, McNemar p < 0.001
Compliance Notes
Record the version of each method’s implementation, the scan’s window shape and maximum size, the weights specification and rate smoothing used for Local Moran’s I, the multiple-testing correction, the replication counts, and the paired discordance results.
When a program adopts one method on the strength of this benchmark, keep the benchmark report with the surveillance protocol. The choice of method is a choice about which outbreaks the program is most likely to miss, and that is a decision reviewers and partner agencies are entitled to see.
Related Topics
- Cluster Detection Power & Benchmarking — the parent guide and benchmark harness.
- Configuring SaTScan for Retrospective Cluster Detection — running the scan in production.
- Calculating Local Moran’s I for Infectious Disease Outbreaks — the local statistic in detail.