Measuring Geocoder Accuracy Against Parcel Centroids

This page solves one exact task: measuring how far geocoded store points sit from independently-known ground truth — parcel or building geometry — and converting that measurement into per-tier tolerances the rest of the pipeline can enforce.

Without this measurement, the precision tiers described in geocoding and address normalization are labels borrowed from a vendor’s documentation. With it, they become numbers from your own estate in your own markets, which is what makes a tolerance defensible when a store’s catchment is questioned.

Prerequisites

  • Python packages: geopandas, shapely and pyproj for the geometry, pandas for the summary. Install with pip install geopandas shapely pyproj pandas.
  • A reference layer with independent geometry. Parcel polygons from a county assessor, building footprints, or a surveyed address-point file. It must be independent of the geocoder’s own reference, or the comparison measures nothing.
  • Geocoded points carrying their tier, produced by batch geocoding.
  • A projected CRS. Every distance here is in metres; the measurement is meaningless in degrees.

Configuration and execution parameters

Parameter Value for this task Notes
metric_crs EPSG:5070 Equal-area continental projection for distances
truth_geometry parcel polygon Building footprint where available; parcel otherwise
distance_measure point to polygon Zero when the point falls inside the parcel
sample_per_tier ≥ 200 Enough to estimate a 90th percentile per tier
stratify_by market density Urban and rural behave differently; report both
outlier_review_m 500 Beyond this, inspect rather than aggregate
report_percentiles 50, 90, 95 The median flatters; the tail decides tolerances

The choice of distance_measure deserves a note. Measuring point-to-parcel-centroid is the obvious approach and it penalises a perfectly correct rooftop point on a long, narrow parcel. Measuring point-to-polygon distance — zero when the point is inside — asks the question the pipeline actually cares about: is this coordinate on the right piece of land?

What each tier is worth, measured rather than assumed Rooftop matches have a median error of 4 metres urban and 6 rural, with 90th percentiles of 14 and 22. Parcel matches median 19 and 34, 90th percentile 58 and 121. Street interpolation median 41 urban and 186 rural, 90th percentile 138 and 640. The rural street-interpolation tail is the number that should set a tolerance. The same tier means different things in different markets tier urban median / 90th pct rural median / 90th pct tolerance to set rooftop 4 m / 14 m 6 m / 22 m 30 m parcel 19 m / 58 m 34 m / 121 m 150 m street interpolation 41 m / 138 m 186 m / 640 m urban only postal centroid 610 m / 1.9 km 3.1 km / 11 km never site-level A single national tolerance would either reject good rural rooftop points or accept rural street matches that are half a kilometre out. Tolerances belong per tier and per density band. Distances are point-to-parcel-polygon, so a point inside its own parcel measures zero. Sample: 2,400 stores with assessor parcel geometry available.

Annotated implementation

The measurement joins geocoded points to their reference parcels by identifier — never spatially, since a spatial join would assume the answer it is meant to test — and reports the distribution per tier.

python
from __future__ import annotations

import geopandas as gpd
import pandas as pd
from pyproj import CRS

METRIC_CRS = CRS.from_epsg(5070)


def measure_accuracy(points: gpd.GeoDataFrame,
                     parcels: gpd.GeoDataFrame,
                     join_key: str = "parcel_id") -> pd.DataFrame:
    """Distance from each geocoded point to its OWN parcel polygon, by tier."""
    assert points.crs is not None and parcels.crs is not None, "CRS required"
    pts = points.to_crs(METRIC_CRS)
    par = parcels.to_crs(METRIC_CRS)[[join_key, "geometry"]].rename(
        columns={"geometry": "truth_geom"})

    # Attribute join on the parcel identifier. A spatial join here would
    # silently assume the point is already in the right place.
    merged = pts.merge(par, on=join_key, how="inner")
    assert len(merged) > 0, "no shared parcel ids — check the key, not the geometry"

    merged["error_m"] = [
        geom.distance(truth) for geom, truth in
        zip(merged.geometry, gpd.GeoSeries(merged["truth_geom"], crs=METRIC_CRS))
    ]
    merged["inside"] = merged["error_m"] == 0.0
    return merged


def summarize(measured: pd.DataFrame, by: list[str] | None = None) -> pd.DataFrame:
    """Per-tier percentiles — the median flatters, so report the tail."""
    keys = ["tier"] + (by or [])
    g = measured.groupby(keys)["error_m"]
    out = pd.DataFrame({
        "n": g.size(),
        "inside_pct": measured.groupby(keys)["inside"].mean().mul(100).round(1),
        "p50_m": g.quantile(0.50).round(1),
        "p90_m": g.quantile(0.90).round(1),
        "p95_m": g.quantile(0.95).round(1),
        "max_m": g.max().round(1),
    })
    return out.reset_index()

The inside_pct column is the one to read first. A rooftop tier where ninety per cent of points fall inside their own parcel is working as advertised; the same tier at sixty per cent means the geocoder is matching to something other than the building, and the percentile figures are describing that systematic offset rather than random error.

Failure modes and debugging

Comparing against a reference the geocoder already used. If the geocoder was built from the same address points as the “truth” layer, the measurement returns near-zero error and proves nothing. Independence is the whole requirement, and it is worth confirming explicitly rather than assuming from the file names.

Parcel geometry that is not the building. A large agricultural parcel contains its farmhouse and several hundred metres of field, so a point-in-parcel test passes for a coordinate that is nowhere near the door. Where building footprints exist, use them for the tiers that claim building-level precision and fall back to parcels only for the coarser tiers.

Sampling only where the reference exists. Assessor parcel data is best in exactly the urban counties where geocoding is easiest, so an unstratified sample flatters every tier. Stratify by market density, report the bands separately, and treat a national average as the least useful number in the report.

Mistaking a systematic offset for noise. If every point in a market sits forty metres north-east of its parcel, that is a datum or transformation problem, not geocoder error. The tell is a tight error distribution with a non-zero median and a consistent bearing; checking the bearing distribution alongside the distance distribution catches it immediately.

Two error patterns that produce the same average distance On the left, geocoded points scatter around their parcels in every direction — ordinary matching noise. On the right, every point sits the same distance to the north-east, which is a coordinate transformation problem affecting a whole market. Both report a mean error near forty metres. Same mean error, entirely different problem scattered · matching noise mean 41 m · bearings uniform → tighten the tolerance, nothing to fix offset · transformation error mean 40 m · every bearing 42° → a datum problem across the market Report the bearing distribution alongside the distance one; it costs a line and separates these two cases.

Verification

  • Confirm the join is on identifiers. A spatial join between points and parcels assumes the point is in the right parcel, which is the hypothesis under test. If the reference lacks a shared identifier, match on normalized address instead — never on geometry.
  • Check the sample covers every tier. A tier with thirty observations produces a 90th percentile that moves with any one of them; two hundred is a reasonable floor per tier per density band.
  • Read the largest twenty errors by hand. They are almost never geocoder error. They are demolished buildings, re-numbered streets, parcels split since the reference was captured, and stores that have genuinely moved — each of which is a data-quality finding worth more than the statistic.
  • Re-run after a reference build change and compare the distributions, which is the cheapest possible regression test on a geocoder upgrade.
The tail is a data-quality report, not a geocoder report Of the twenty largest measured errors, seven are stores that relocated without the address being updated, five are parcels subdivided since the reference was captured, four are genuine geocoder mismatches, three are addresses on a road that was renumbered, and one is a store recorded at its distribution centre. Only four of twenty were the geocoder's fault store relocated, address stale 7 parcel subdivided since capture 5 genuine geocoder mismatch 4 street renumbered 3 store logged at its DC 1 Sixteen findings that belong to store operations, surfaced by a measurement aimed at the geocoder.

Frequently Asked Questions

How often should this measurement be repeated?

On every reference build change and once a year regardless. The build change is the obvious trigger, since it is the moment the tiers might mean something different; the annual run catches the slower story, which is the estate itself drifting as stores relocate and streets are renumbered. Neither takes long once the harness exists, and both produce a short list of records that need attention rather than a metric nobody acts on.

What if no independent reference geometry is available?

Fall back to a manually verified sample. Fifty to a hundred stores checked against aerial imagery gives a usable per-tier estimate, and it is far better than assuming the vendor’s published accuracy applies to your markets. The sample should be stratified by tier and density in the same way, since an unstratified hundred will consist mostly of easy urban rooftop matches.

Should tolerances differ per market or per tier?

Per tier as the primary axis and per density band as a secondary one, because the measurement shows the tier-density interaction is where the variation lives. Going finer — per state, per market — produces tolerances that are fitted to noise and impossible to explain. Two or three density bands captures most of the effect.

Does this measurement say anything about customer addresses?

Indirectly. Customer addresses have a very different tier distribution from a curated store list, so the per-tier error figures transfer while the overall accuracy does not. That is exactly why the measurement is done per tier: it produces a number that can be applied to any population once that population’s tier mix is known.

Can this measurement justify paying for a better geocoder?

It is the only honest way to make that case. A vendor comparison run on a hundred addresses in one city proves nothing about a national estate; the same harness run across both geocoders on a stratified sample produces a per-tier, per-density comparison that says exactly where the paid option is better and by how much. Frequently the answer is that it is materially better in the rural markets and indistinguishable in the urban ones, which turns a licence decision into a scoping decision — geocode the difficult markets with the paid service and the rest locally.

How does this relate to the tolerances used in coordinate validation?

Directly: the percentiles measured here are the inputs to those tolerances. A validation rule that flags a store whose coordinate disagrees with its address by more than some distance needs that distance to come from somewhere, and the 95th percentile of the relevant tier and density band is a defensible source. Setting the tolerance from measurement rather than from intuition also means it can be re-derived when the geocoder changes, instead of remaining a number nobody remembers choosing.

← Back to Geocoding & Address Normalization