Measuring Geocoding Accuracy Against Reference Points
A geocoder tells you it made a “street-level” match. Nobody downstream can act on that unless somebody converts the label into metres. This guide runs that conversion: choosing an independent reference layer, drawing a sample that represents the places your data actually lives, computing displacement statistics per match level and per settlement type, and publishing the result as a lookup that fitness decisions can cite. It is the measurement step behind the gate described in Geocoding and Address Data Validation.
Prerequisites
- geopandas 0.14+, shapely 2.0+, numpy 1.24+ and pandas 2.x.
- An independent reference layer of address points with a known positional accuracy — a national address gazetteer, a surveyed point layer, or verified building centroids. Independent means not derived from the same reference data the geocoder uses.
- A stable join key between your records and the reference. A unique property reference number is ideal; a normalised address string is workable but introduces its own matching error, which must be reported separately from the displacement.
- A projected CRS in metres covering your study area. Displacement computed in degrees is not a distance, and the error grows with latitude.
- A recorded provider version and reference vintage for the geocoding run being measured, as set out in the parent topic. A displacement statistic without those two fields cannot be compared with the next one.
Step-by-Step Procedure
Step 1 — Characterise the reference layer before trusting it
# accuracy/step1_reference.py
import geopandas as gpd
def reference_profile(path: str, accuracy_field: str | None = None) -> dict:
"""What the reference layer is, and how good it claims to be."""
ref = gpd.read_file(path)
profile = {
"features": len(ref),
"crs": str(ref.crs),
"geom_types": sorted(ref.geom_type.unique().tolist()),
"has_duplicate_keys": bool(ref["uprn"].duplicated().any()) if "uprn" in ref else None,
"stated_accuracy_m": None,
}
if accuracy_field and accuracy_field in ref:
profile["stated_accuracy_m"] = float(ref[accuracy_field].median())
return profile
Verification: the reference must contain points, not polygons — comparing a geocoded point to a parcel polygon measures containment, not displacement. If only polygons are available, convert to representative points explicitly and note that the conversion adds its own error, typically half the parcel’s shorter dimension.
Step 2 — Draw a stratified sample
Sampling by convenience produces an urban-only estimate and a false sense of precision about rural matches.
# accuracy/step2_sample.py
import geopandas as gpd
import pandas as pd
STRATA = ["settlement_type", "match_level"]
MIN_PER_STRATUM = 30
TARGET_PER_STRATUM = 60
def stratified_sample(gdf: gpd.GeoDataFrame, seed: int = 20260806) -> gpd.GeoDataFrame:
"""Even coverage across settlement type and match level, not across record volume."""
groups = []
for keys, g in gdf.groupby(STRATA):
if len(g) < MIN_PER_STRATUM:
groups.append(g) # take the whole thin stratum
continue
groups.append(g.sample(min(TARGET_PER_STRATUM, len(g)), random_state=seed))
sample = pd.concat(groups).reset_index(drop=True)
sample.attrs["seed"] = seed
return sample
Verification: print the per-stratum counts. Any stratum under 30 records gives an unstable 95th percentile — report it with the count attached so readers can discount it, rather than quietly folding it into a headline figure.
Step 3 — Join and project into metres
# accuracy/step3_join.py
import geopandas as gpd
def join_to_reference(sample: gpd.GeoDataFrame, reference: gpd.GeoDataFrame,
key: str = "uprn", metric_epsg: int = 27700):
"""Inner join on a stable key, both sides projected to a metric CRS."""
a = sample.to_crs(metric_epsg).set_index(key)
b = reference.to_crs(metric_epsg).set_index(key)
matched = a.index.intersection(b.index)
unmatched = a.index.difference(b.index)
joined = a.loc[matched].copy()
joined["ref_geometry"] = b.loc[matched].geometry
joined["displacement_m"] = joined.geometry.distance(joined["ref_geometry"])
return joined, list(unmatched)
Verification: the unmatched list must be reported, not discarded. A sample where 20% of records have no reference counterpart is measuring a biased subset — usually the well-known addresses — and will understate error.
Step 4 — Compute the statistics that matter
# accuracy/step4_stats.py
import numpy as np
import pandas as pd
def displacement_table(joined: pd.DataFrame) -> pd.DataFrame:
"""Per-stratum displacement summary — median, p95, RMSE and count."""
def summarise(g: pd.Series) -> pd.Series:
return pd.Series({
"n": int(g.size),
"median_m": round(float(g.median()), 2),
"p90_m": round(float(np.percentile(g, 90)), 2),
"p95_m": round(float(np.percentile(g, 95)), 2),
"max_m": round(float(g.max()), 2),
"rmse_m": round(float(np.sqrt((g ** 2).mean())), 2),
})
by_level = joined.groupby("match_level")["displacement_m"].apply(summarise).unstack()
by_both = (joined.groupby(["settlement_type", "match_level"])["displacement_m"]
.apply(summarise).unstack())
return by_level, by_both
Verification: the per-level medians should increase monotonically from rooftop through parcel, interpolated, street and postcode. If they do not — if street matches beat parcel matches, say — the provider’s labels do not mean what the documentation claims, which is a finding worth escalating.
Step 5 — Publish the lookup with its provenance
# accuracy/step5_publish.py
import json
from datetime import date
def publish_lookup(by_level, provider: str, provider_version: str,
reference_name: str, reference_vintage: str,
reference_accuracy_m: float, sample_seed: int, out_path: str) -> dict:
doc = {
"measured_on": date.today().isoformat(),
"provider": provider,
"provider_version": provider_version,
"reference": {"name": reference_name, "vintage": reference_vintage,
"stated_accuracy_m": reference_accuracy_m},
"sample_seed": sample_seed,
"match_level_metres": by_level.to_dict(orient="index"),
"caveat": ("Displacement includes the reference layer's own uncertainty; "
"values below that figure are not resolvable."),
}
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(doc, fh, indent=2)
return doc
Verification: the published document should be small enough to read in full and complete enough to reproduce. Anyone quoting “street matches are good to 40 metres” must be able to trace that number to a provider version, a reference vintage and a sample seed.
Interpreting Results
| Pattern in the results | What it indicates | Response |
|---|---|---|
| Rooftop median ≈ reference accuracy | Measurement is at the floor of what the reference can resolve | Report as “within reference uncertainty”; do not claim better |
| Street p95 several times the median | Long segments with sparse numbering | Exclude street matches from distance-sensitive analysis |
| Rural postcode p95 in kilometres | Expected — large rural postcode areas | Use postcode matches only for regional aggregates |
| A match level with worse stats than the level below it | Provider labels are unreliable | Escalate to the provider; stop gating on that label |
| Displacement clustered at a constant value | Systematic offset, often a datum or reference-alignment issue | Check the CRS handling on both sides before blaming the geocoder |
| Very low variance in one stratum | Fallback: many records sharing a coordinate | Cross-check with the fallback detection in the sibling guide |
The two headline numbers to publish per match level are the median — what a typical result is worth — and the 95th percentile — the risk you are accepting. Quoting a mean invites the reader to imagine a symmetric distribution that does not exist.
Gotchas & Edge Cases
Displacement includes the reference layer’s uncertainty. If the reference is accurate to two metres and rooftop matches measure at 2.4 metres, the geocoder is not being measured — the reference floor is. Say so in the report rather than claiming sub-metre performance.
A join on address strings measures two things at once. String-matched joins contribute their own false pairings, and a false pair produces a large spurious displacement. Where a stable identifier is unavailable, report the string-match rate and treat displacement above a sanity threshold (say, 2 km in an urban area) as a join failure rather than a geocoding error.
Sampling on the geocoded layer biases toward what geocoded successfully. Draw the sample from the input address list, not from the output points, so unmatched records stay visible in the denominator.
Settlement type is not always in the data. Derive it if necessary — population density around the reference point, or a settlement boundary join — and store it, because a national average with no urban/rural split hides the entire useful signal.
Repeat runs must reuse the seed. Comparing this year’s accuracy to last year’s across two different random samples confuses sampling variation with real change. The seed belongs in the published document for exactly this reason.
When to Escalate
- Non-monotonic match-level statistics mean the provider’s labels cannot be trusted as a gate. Raise it with the provider and, in the meantime, gate on measured displacement bands you define yourself.
- A large share of the sample has no reference counterpart — before drawing conclusions, resolve whether that is a reference coverage gap or a systematic exclusion in your own data.
- Accuracy has degraded since the previous measurement with no change in provider version. This usually indicates a reference-data update on the provider’s side; ask for the vintage and re-baseline.
- Rooftop displacement exceeds the fitness threshold for emergency or dispatch use — that is a service-level breach rather than a data-quality observation, and belongs in the governance escalation path described in Data Stewardship Roles and Responsibilities.
Frequently Asked Questions
Why report the 95th percentile instead of the mean displacement?
Displacement distributions are heavily right-skewed: most matches are close and a few are very wrong. A mean is dragged upward by the tail and describes no actual address; the median describes the typical case and the 95th percentile describes the risk. Fitness decisions are made against the tail, because the question is how often a result is unusably wrong, not how good a typical result is.
Does the reference layer need to be perfect?
No, but its own accuracy must be known and materially better than what you are measuring. A reference with two-metre uncertainty is fine for assessing street interpolation that is tens of metres out, and useless for assessing rooftop matches at three metres. Record the reference accuracy in the report so the measurement stays interpretable.
How often should the measurement be repeated?
Whenever the provider version or the reference vintage changes, and otherwise annually. Geocoder behaviour drifts with reference-data updates, and a match-level lookup measured three years ago is usually quoted with more confidence than it deserves. Keeping the sample fixed between runs makes the comparison considerably stronger.
Can I use the geocoder's own reference data as ground truth?
No — that measures self-consistency, not accuracy. If the geocoder places an address at the same point its reference file states, the displacement is zero by construction regardless of where the building actually is. The reference must be independent, which usually means a surveyed layer or a different authority's address file.
Related
- Geocoding and Address Data Validation — the wider check set and the fitness gate this measurement feeds
- Detecting Centroid Fallback in Geocoded Results — the defect that produces suspiciously low variance in a stratum
- Aligning Spatial Metadata with ISO 19157 — encoding positional accuracy as a conformance statement