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,shapelyandpyprojfor the geometry,pandasfor the summary. Install withpip 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?
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.
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.
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.
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.
Related
- Geocoding & Address Normalization — the tiers this page quantifies.
- Detecting Geocoding Outliers with Spatial Clustering — finding the individual bad points this measurement summarises.
- Validating Spatial Join Accuracy with Ground Truth — the same discipline applied one stage downstream.
← Back to Geocoding & Address Normalization