Detecting Coordinate Drift Between Geocoding Vintages
Reference layers improve continuously, so re-geocoding an archive against a newer one raises the match rate and moves coordinates that were already matched. Both effects change published numbers, and neither is visible in the output unless it is measured. This guide, part of Geocoding Quality & Address Standardization, covers measuring drift between vintages, deciding which records to overwrite, and keeping a trend series interpretable across the change.
Problem Context & Constraints
Re-geocoding produces four kinds of change and only one of them is unambiguously good. Records that previously failed and now match are a genuine improvement, though they change the analysed denominator. Records whose match type improves — a ZIP centroid becoming a rooftop — are also an improvement, and they can move a case by kilometres. Records that matched before and still match at the same type may still move, by metres or occasionally by much more, as the reference geometry is refined. And a small number regress, matching worse than before, usually because an address range was split.
The reason this matters is that surveillance outputs are almost always series. A tract rate published quarterly is compared with the previous quarter, and if the geocoding vintage changed between them, part of the difference is the reference layer rather than the disease. Nothing in the data announces which part.
The constraint that makes this tractable is that drift is measurable exactly. Both vintages are deterministic functions of the same input addresses, so re-running the old vintage is possible and the difference can be attributed record by record.
Prerequisites
- Both geocoding runs stored with their run signatures — reference vintage, geocoder version and configuration hash — as produced by the parent guide’s pipeline
- The original input addresses, unchanged, so the old vintage is reproducible rather than merely remembered
python3.11,pandas2.2.2,geopandas1.0.1,numpy1.26.4- A metric CRS appropriate to the study extent, since drift must be measured in metres
Step-by-Step Solution
# Compare two geocoding vintages record by record and classify the change.
# Pinned: pandas==2.2.2, geopandas==1.0.1, numpy==1.26.4
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("geocode.drift")
METRIC_CRS = "EPSG:5070"
def classify_drift(old: gpd.GeoDataFrame, new: gpd.GeoDataFrame,
id_col: str = "record_id") -> pd.DataFrame:
"""Per-record change class and displacement between two vintages."""
a = old.set_index(id_col).to_crs(METRIC_CRS)
b = new.set_index(id_col).to_crs(METRIC_CRS)
idx = a.index.union(b.index)
a, b = a.reindex(idx), b.reindex(idx)
out = pd.DataFrame(index=idx)
out["rank_old"] = a["match_rank"].fillna(9).astype(int)
out["rank_new"] = b["match_rank"].fillna(9).astype(int)
both = a.geometry.notna() & b.geometry.notna()
out["moved_m"] = np.nan
out.loc[both, "moved_m"] = a.loc[both].geometry.distance(b.loc[both].geometry)
cond = [
(out["rank_old"] == 9) & (out["rank_new"] < 9),
(out["rank_new"] < out["rank_old"]),
(out["rank_new"] > out["rank_old"]),
(out["rank_new"] == out["rank_old"]) & (out["moved_m"].fillna(0) > 1.0),
]
labels = ["newly_matched", "type_improved", "regressed", "same_type_moved"]
out["change"] = np.select(cond, labels, default="unchanged")
summary = out["change"].value_counts().to_dict()
log.info("drift classes: %s", summary)
for cls in ["type_improved", "same_type_moved"]:
d = out.loc[out["change"] == cls, "moved_m"].dropna()
if len(d):
log.info("%s: median move %.1f m, p95 %.1f m", cls, d.median(), d.quantile(0.95))
return out
def unit_reassignment(old_units: pd.Series, new_units: pd.Series) -> pd.DataFrame:
"""How many records changed reporting unit, and which units gained or lost.
This is the number that decides whether a trend series can be continued: a
coordinate that moves 40 m matters only if it crosses a boundary."""
changed = old_units.ne(new_units) & old_units.notna() & new_units.notna()
log.info("%d records changed reporting unit (%.2f%%)",
int(changed.sum()), 100 * changed.mean())
delta = (new_units[changed].value_counts()
.subtract(old_units[changed].value_counts(), fill_value=0)
.sort_values())
return delta.rename("net_change").to_frame()
The second function is the one that decides the operational question. A 12-metre median move is irrelevant to a tract rate unless the moved record crosses a tract boundary, and the count of boundary crossings is a much smaller and much more actionable number than the count of moved coordinates.
Validation & Edge Cases
1. Re-run the old vintage rather than trusting the stored output. If the archive’s stored coordinates were produced by a pipeline that has since changed in any way — a different normalization rule, a different tie-break — then the comparison measures both the reference change and the code change together. Reproduce the old vintage from the pinned signature first.
2. Watch for whole-neighbourhood shifts. Reference-layer refinements sometimes move an entire street’s interpolation basis. That appears as a cluster of records with nearly identical displacement vectors, and it is the one drift pattern that can move a rate materially on its own. Group displacements by street name and flag any group whose median move exceeds a threshold.
INFO drift classes: {'unchanged': 37551, 'same_type_moved': 2340, 'type_improved': 1104, 'newly_matched': 612, 'regressed': 186}
INFO type_improved: median move 1904.2 m, p95 8871.0 m
INFO same_type_moved: median move 12.1 m, p95 61.4 m
INFO 341 records changed reporting unit (0.82%)
WARNING street-level cluster: 'COUNTY ROAD 12' — 46 records moved a median of 212 m in a common direction
3. Decide the overwrite policy before looking at the results. The defensible default is to accept improvements and newly matched records, keep the old coordinate for regressions, and record every decision. Deciding after seeing which choice produces a nicer trend line is the failure this whole procedure exists to prevent.
4. Do not re-geocode a closed reporting period silently. If a published figure was computed from the old vintage, the archive now disagrees with the publication. Either restate the published figure with a note or keep the vintage the publication used, retrievable by signature. Both are defensible; neither happens by accident.
5. Re-geocode the whole series, not the newest period. Applying a new vintage only going forward guarantees a break at the changeover. Where the archive is small enough, recomputing every period on the current vintage is the cleanest answer and costs a batch run.
6. Separate drift caused by the reference layer from drift caused by your own code. A normalization change, a new tie-break rule or an upgraded parsing library all move coordinates in ways that look identical to a reference-layer refinement. Running the old code against the new reference, and the new code against the old reference, decomposes the two in two extra runs and turns “coordinates moved” into “coordinates moved because of X”.
7. Watch for drift that crosses a disclosure boundary. A record that moves from a tract with twelve cases to one with four changes both tracts’ suppression status, so a re-geocoding run can silently alter which cells are publishable. Re-run the suppression logic and diff its output rather than assuming it is unaffected.
Plotting the displacement distribution before classifying is the quickest way to see whether the two causes are separable in this archive:
Compliance Notes
- Version the coordinates, not just the addresses. The archive should be able to answer “where did we think this case was in March 2025” without ambiguity, which means storing the vintage alongside the coordinate rather than overwriting in place.
- Record the overwrite policy with the run, including the treatment of regressions, so a later reviewer can see that the rule preceded the results.
- Note vintage changes in published metadata. Any figure derived from a re-geocoded archive should carry the vintage identifier, exactly as it carries the CRS, so a comparison against an older publication is possible.
- Treat a large drift as a disclosure event to re-check. Records that move across a reporting boundary can change which cells fall below a suppression threshold, so a re-geocoded archive needs its disclosure review re-run rather than inherited from the previous vintage.
Related Topics
- Geocoding Quality & Address Standardization — the parent guide, where the run signature that makes this comparison possible is defined.
- Choosing a Geocoder for Protected Health Data — the deployment decision that determines how often vintages change under you.
- Areal Interpolation & Boundary Harmonization — the companion problem, where the geography moves rather than the case.