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.

Four Kinds of Change, One Re-Geocoding Run Re-geocoding 41,793 records against a newer reference layer produces four outcomes. 612 records that previously failed now match, changing the analysed denominator. 1,104 records improve match type, and the median coordinate move for those is 1.9 kilometres. 39,891 records keep the same match type, of which 2,340 still move, with a median move of 12 metres. And 186 records regress to a coarser match type, usually because an address range was split between the vintages. Only the first column is unambiguously an improvement Newly matched 612 denominator changes for every prior period Type improved 1,104 median move 1.9 km tract assignment flips Same type, moved 2,340 median move 12 m mostly harmless Regressed 186 address range split keep the old coordinate 4,242 of 41,793 records changed — 10.1% of the archive enough to move a tract rate, and invisible unless the two vintages are compared directly Compare vintages before adopting one, not after a trend line bends

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
  • python 3.11, pandas 2.2.2, geopandas 1.0.1, numpy 1.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.

The Step That Was a Reference Layer A quarterly tract incidence series rising gently across eight quarters with a visible step upward between the fifth and sixth. A marker shows where the geocoding vintage changed. The dashed line shows the same series recomputed with every quarter geocoded on the new vintage: the step disappears and the underlying trend is smooth. The apparent jump was 612 newly matched records entering the numerator, not an increase in disease. The step is the reference layer, not the disease 0 20 40 rate / 100k vintage change recomputed on one vintage throughout as published Q1 Q4 Q7 Recompute the whole series on one vintage, or label the break — never leave it unexplained

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:

Displacement Distribution Between Vintages Distribution of coordinate displacement between two geocoding vintages, on a logarithmic scale. Most moved records shift under twenty metres, a mode that reflects reference geometry refinement. A second, much smaller mode sits between one and ten kilometres and corresponds to records whose match type improved from a centroid to a rooftop. The gap between the two modes is what makes the classification separable. Two modes, two different causes geometry refinement match type improved 1 m 100 m 1 km 30 km displacement between vintages (log scale)

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.