Detecting Distribution Drift in Daily Case Geography
This guide, part of Spatial Data Quality Monitoring, covers the distribution family of checks: detecting that the geography of today’s data differs from recent history for a reason that is not epidemiological.
Problem Context & Constraints
The failures this check exists for share a signature: every record is well-formed, every geometry is valid, every count reconciles, and the output is still wrong. A geocoder reference update shifts a few thousand addresses from street-segment matches to rooftop matches and moves them tens of metres — enough to cross tract boundaries. A laboratory switches its reporting system and begins sending the ordering provider’s address instead of the patient’s. A county’s feed starts truncating the street number, so everything falls back to ZIP centroids.
Each produces a change in the distribution of case geography with no change in the underlying disease. A cluster statistic sees the change and cannot tell it from transmission.
Three constraints make detection hard. Real epidemiological change also shifts the distribution — an outbreak concentrates cases in one area by definition, so a drift detector that halts on concentration would suppress surveillance exactly when it matters. Daily counts are small and noisy, so a naive comparison of proportions fires constantly. And the drift is often in a variable nobody analyses directly — the match-type mix, the distance to the previous geocode of the same address — which is why it goes unnoticed.
The resolution is to monitor the variables that are informative about data quality and uninformative about disease: match-type mix, reporting-source mix, and displacement of recurring addresses between geocoder vintages. Disease does not change how addresses geocode.
Prerequisites
python3.11pandas2.2.2,numpy1.26.4,scipy1.13.1,geopandas0.14.4- Per-record geocode match type and reporting source
- A stable address key, so recurring addresses can be compared across geocoder vintages
- At least several months of daily history of each monitored quantity
Step-by-Step Solution
Compute three disease-insensitive indicators and one disease-sensitive one, compare each against a pooled historical baseline with a test that accounts for batch size, and escalate only the disease-insensitive ones.
# Drift detection on case geography with disease-insensitive indicators.
# Pinned: pandas==2.2.2, numpy==1.26.4, scipy==1.13.1, geopandas==0.14.4
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
from scipy.stats import chi2_contingency
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dq.drift")
def categorical_drift(today: pd.Series, history: pd.Series, name: str,
alpha: float = 0.001, min_expected: float = 5.0) -> dict:
"""Chi-square of today's category counts against pooled history.
Categories with tiny expected counts are merged into 'other' so the test is
not dominated by a rare category appearing once.
"""
cats = history.index.union(today.index)
h = history.reindex(cats).fillna(0)
t = today.reindex(cats).fillna(0)
expected = h / h.sum() * t.sum()
rare = expected < min_expected
if rare.any():
h = pd.concat([h[~rare], pd.Series({"_other": h[rare].sum()})])
t = pd.concat([t[~rare], pd.Series({"_other": t[rare].sum()})])
chi2, p, dof, _ = chi2_contingency(np.vstack([t.values, h.values]) + 0.5)
shift = (t / t.sum() - h / h.sum()).sort_values(key=abs, ascending=False)
out = {"indicator": name, "chi2": float(chi2), "p": float(p), "dof": int(dof),
"drifted": bool(p < alpha), "largest_shift": shift.head(3).round(4).to_dict(),
"n_today": int(t.sum())}
(log.warning if out["drifted"] else log.info)(
"%-24s p=%.2e drifted=%s largest=%s", name, p, out["drifted"],
out["largest_shift"])
return out
def recurring_address_displacement(today: gpd.GeoDataFrame, prior: gpd.GeoDataFrame,
key: str = "addr_key",
tolerance_m: float = 30.0) -> dict:
"""How far did addresses seen before move when geocoded again today?
Disease cannot move an address. A distribution of displacements that grows
means the reference data or the geocoder changed.
"""
m = today[[key, "geometry"]].merge(prior[[key, "geometry"]], on=key,
suffixes=("_t", "_p"))
if m.empty:
return {"indicator": "address_displacement", "n_recurring": 0, "drifted": False}
d = gpd.GeoSeries(m["geometry_t"], crs=today.crs).distance(
gpd.GeoSeries(m["geometry_p"], crs=today.crs))
moved = d > tolerance_m
out = {"indicator": "address_displacement", "n_recurring": int(len(m)),
"share_moved": float(moved.mean()), "p95_move_m": float(np.percentile(d, 95)),
"drifted": bool(moved.mean() > 0.02)}
(log.warning if out["drifted"] else log.info)(
"address_displacement recurring=%d moved>%.0fm=%.2f%% p95=%.0fm drifted=%s",
out["n_recurring"], tolerance_m, 100 * out["share_moved"],
out["p95_move_m"], out["drifted"])
return out
def classify_drift(results: list[dict]) -> str:
"""Escalate only on disease-insensitive indicators."""
insensitive = {"match_type_mix", "reporting_source_mix", "address_displacement"}
data_drift = [r for r in results if r["indicator"] in insensitive and r["drifted"]]
geo_shift = [r for r in results if r["indicator"] == "county_share" and r["drifted"]]
if data_drift:
verdict = "DATA_DRIFT"
elif geo_shift:
verdict = "GEOGRAPHIC_SHIFT_ONLY" # could be disease; warn, do not halt
else:
verdict = "STABLE"
log.info("drift verdict: %s", verdict)
return verdict
The classification rule is the point of the whole design. A shift in the county share with no movement in the disease-insensitive indicators is a candidate epidemiological signal and should go to the analysts, not halt the pipeline. A shift in the match-type mix is a data problem regardless of what the county share does, and should halt or at least block publication until someone looks.
The recurring-address check is the most specific instrument available. An address geocoded last month and again today should land in the same place. When the share of recurring addresses that moved rises from its usual fraction of a per cent to several per cent, the geocoder or its reference data changed, and nothing about disease could have produced that.
Validation & Edge Cases
Small batches. A Sunday with forty records has little power to detect a mix change and a high chance of a spurious one. Merging rare categories and using a strict significance level handles most of it; alternatively, run the categorical tests on a rolling multi-day window.
Baseline contamination. If the history window contains a previous drift incident, the baseline absorbs it. Exclude known incident periods from the baseline and record the exclusions.
Legitimate change in the reporting-source mix. A new laboratory joining the feed shifts the source mix permanently. That is correct to flag, and once confirmed the baseline must be reset from the change date rather than letting the alert fire for months.
Displacement from genuine address correction. A small fraction of recurring addresses move because the original geocode was wrong and has been fixed. That is the baseline level and should be quiet; the check measures departures from it.
Drift that arrives gradually. A feed that degrades by a percentage point a week never trips a test against a rolling baseline, because the baseline moves with it. Keep one comparison against a fixed reference period alongside the rolling one; the fixed comparison catches slow degradation and the rolling one catches abrupt changes, and neither does both.
Drift confined to one jurisdiction. A state-level test of the match-type mix can pass while one county’s feed has collapsed to ZIP centroids, because that county is a small share of the total. Run the categorical tests per reporting jurisdiction as well as pooled, correcting for the number of jurisdictions tested so the per-county checks do not produce a false alarm most nights.
Coordinate reference system mismatch between runs. Comparing today’s projected coordinates with a prior run’s geographic coordinates produces enormous “displacements” for every address. Assert both are in the working system before measuring distance.
WARNING dq.drift match_type_mix p=3.14e-21 drifted=True largest={'street_interpolated': -0.1822, 'rooftop': 0.1706}
WARNING dq.drift address_displacement recurring=842 moved>30m=11.16% p95=64m drifted=True
INFO dq.drift county_share p=4.02e-01 drifted=False largest={'06075': 0.0121}
INFO dq.drift drift verdict: DATA_DRIFT
The per-jurisdiction test in that list needs a multiple-comparison correction, and the arithmetic of testing many counties every night explains why:
Compliance Notes
Record, per run, each indicator with its test statistic, p-value and largest category shifts; the baseline window and any excluded incident periods; the displacement tolerance and the share of recurring addresses that moved; and the drift verdict with the action taken.
A data drift that reaches publication has a disclosure dimension as well as an accuracy one: if a geocoder change moved cases across a suppression boundary, a cell that was safely above the minimum may now be below it, or the reverse. Where a drift verdict is raised, the suppression stage should be re-run against the corrected data before anything is released, and the incident belongs in the product’s lineage record.
Related Topics
- Spatial Data Quality Monitoring — the parent guide and where drift checks sit among the families.
- Detecting Coordinate Drift Between Geocoding Vintages — the one-off analysis this nightly check automates.
- Controlling False Alarms in Daily Prospective Surveillance — the downstream statistic a data drift would otherwise fool.