Interpreting Moran Scatterplot Quadrants for Surveillance
This guide, part of Global & Local Moran’s I Implementation, is about the step after the statistics: turning the four Local Moran’s I quadrant labels into decisions an epidemiologist can act on.
Problem Context & Constraints
The Moran scatterplot places every area on two axes. The horizontal axis is the area’s own standardised value — its rate, relative to the mean, in standard deviations. The vertical axis is the spatial lag: the weighted average of its neighbours’ standardised values. The four quadrants that result are the source of the familiar labels. High-high areas are high and surrounded by high; low-low are low among low; high-low are high among low neighbours; low-high are low among high neighbours.
The labels are easy to generate and easy to misread. A high-high cluster is often read as “the hotspot”, when it may be a broad region of moderately elevated risk with no single focus. A high-low outlier is often ignored as noise, when it may be the earliest sign of a new point-source outbreak — a single area with sudden excess, before cases spread to its neighbours. A low-high area is often read as protective, when it may be an area where cases are under-ascertained because the local clinic closed.
Two constraints decide how much a label can support. Significance: only labels that survive multiple-testing correction — see Applying FDR Correction to Local Moran’s I Results — should drive action. Magnitude: a statistically significant high-high area can still have a rate barely above average, and investigators need to know the excess cases involved, not only the label.
Prerequisites
python3.11numpy1.26.4,pandas2.2.2,geopandas0.14.4libpysal4.10,esda2.5.1,scipy1.13.1- A Local Moran’s I result with FDR-corrected labels
- Case counts, expected counts and populations for each area, so excess cases can be reported alongside labels
- Supporting context per area: reporting facility counts, testing volumes or completeness indicators where available
Step-by-Step Solution
Translate each significant label into an action category, attach the magnitude of the excess, group contiguous areas with the same label into clusters, and add the context flags that change the reading.
# Turn Local Moran's I quadrant labels into surveillance action categories.
# Pinned: numpy==1.26.4, pandas==2.2.2, geopandas==0.14.4, libpysal==4.10, scipy==1.13.1
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
from libpysal.weights import Queen, w_subset
from scipy.sparse.csgraph import connected_components
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("quadrants")
ACTION = {
"HH": "regional excess - coordinate response across areas",
"HL": "local spike - investigate for point source",
"LH": "low among high - check ascertainment before reading as protective",
"LL": "low region - check completeness; no outbreak action",
}
def excess(gdf: gpd.GeoDataFrame) -> pd.Series:
"""Observed minus expected cases; expected from the reference rate."""
return gdf["cases"] - gdf["expected"]
def group_clusters(gdf: gpd.GeoDataFrame, label_col: str = "label_fdr") -> pd.Series:
"""Give contiguous areas sharing a significant label a common cluster id."""
w = Queen.from_dataframe(gdf, use_index=False)
cluster = pd.Series(pd.NA, index=gdf.index, dtype="object")
for lab in ("HH", "LL", "HL", "LH"):
idx = np.flatnonzero(gdf[label_col].to_numpy() == lab)
if idx.size == 0:
continue
sub = w_subset(w, list(idx))
n, comp = connected_components(sub.sparse, directed=False)
for pos, c in zip(sub.id_order, comp):
cluster.iloc[pos] = f"{lab}-{c + 1}"
return cluster
def interpret(gdf: gpd.GeoDataFrame, completeness_col: str = "completeness") -> pd.DataFrame:
out = gdf.copy()
out["excess_cases"] = excess(out)
out["cluster"] = group_clusters(out)
sig = out[out["label_fdr"] != "ns"].copy()
sig["action"] = sig["label_fdr"].map(ACTION)
low_complete = sig[completeness_col] < 0.8
sig["caution"] = np.where(
sig["label_fdr"].isin(["LH", "LL"]) & low_complete,
"low reporting completeness - low rate may be an artefact", "")
summary = (sig.groupby("cluster")
.agg(label=("label_fdr", "first"), areas=("geoid", "count"),
excess_cases=("excess_cases", "sum"), action=("action", "first"))
.sort_values("excess_cases", ascending=False))
log.info("significant areas=%d in %d clusters; HL spikes=%d",
len(sig), len(summary), int((sig["label_fdr"] == "HL").sum()))
return summary
Ranking clusters by excess cases, not by the Local Moran’s I value, puts investigator attention where the public health burden is. A large local statistic in a small area can involve three extra cases; a modest statistic across a dozen populous tracts can involve two hundred.
Grouping contiguous same-label areas turns a scatter of labels into a small list of clusters, each with one action and one excess count. That list is what the weekly surveillance meeting needs, not a map with 40 separately labelled tracts. It also gives each cluster a stable identifier that later periods can refer back to, so the team can say whether the cluster it discussed last week has grown, shrunk or gone, and can close out investigations with a record of which cluster prompted them.
Validation & Edge Cases
High-high as a halo. A single area with a very high rate raises its neighbours’ spatial lag, so neighbours with ordinary rates can be pulled into the high-high quadrant. Check each high-high area’s own rate: members of a cluster whose own z-score is near zero are being carried by the core, and the cluster’s true extent is smaller than the label map suggests.
High-low outliers dismissed as noise. Small-population areas produce high-low outliers from random variation, which is why raw rates should be smoothed first. After smoothing and correction, a surviving high-low area deserves a look, because that is the pattern a new point-source outbreak produces in its first days.
Low-high read as protective. A low rate among high neighbours can be real — a vaccinated community, a population with less exposure — or an artefact of under-reporting. Attach reporting completeness, laboratory testing volume or the number of reporting providers; the code flags low-high and low-low areas with completeness below 80 percent.
Weights that cross barriers. Contiguity across a river, a state line or a large park makes areas neighbours that share little. Quadrant labels computed with such weights can pair a high area with an unrelated low one. Review the weights on the geography, especially along natural barriers.
Standardisation choice. The scatterplot uses z-scores of the variable. If the variable is a raw rate with heavy skew, a few extreme areas dominate the standardisation and push everything else towards the centre. Use a smoothed rate or a log-transformed rate, and state which.
Clusters that straddle a jurisdiction line. A high-high cluster whose edge sits on the county or state boundary is often only part of a larger cluster whose remainder lies in data the program does not hold. The label map shows a crescent against the border when the real pattern is a circle centred across it. Flag clusters that touch the study-area edge, and treat them as a prompt to contact the neighbouring health department rather than as a complete description of the event.
Quadrant labels on counts instead of rates. Running Local Moran’s I on raw case counts produces high-high clusters wherever population is dense, because counts track population. The resulting map shows where people live. Always label rates or ratios of observed to expected, never counts, and check the input column name in the configuration before a new analyst runs the workflow for the first time.
Labels without trend. A high-high cluster that has been present for five years is an endemic disparity; one that appeared this month is an outbreak signal. Keep last period’s cluster table and mark each cluster new, persistent or resolved, since the response differs.
INFO quadrants significant areas=21 in 4 clusters; HL spikes=1
INFO quadrants HH-1: 12 areas, 3 with own z < 0.5 (halo members)
Compliance Notes
Record the label set used (corrected or raw), the weights specification, the variable and its transformation, the completeness threshold for caution flags, and the cluster table with excess cases for each reporting period.
Action categories that name specific small areas can themselves be disclosive when cases are few — a single high-low tract with fourteen excess cases in a small town may identify an institution or a family. Keep the full cluster table internal, and apply the program’s small-cell rules before any area-level list leaves the surveillance team.
Related Topics
- Global & Local Moran’s I Implementation — the parent guide.
- Applying FDR Correction to Local Moran’s I Results — producing the labels this guide interprets.
- Getis-Ord Gi* Hotspot Detection — a hot/cold statistic without outlier quadrants.