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.

The measurement, end to endSequence diagram between the sampler, the geocoded layer, the reference layer and the published lookup. The sampler draws a stratified sample, joins it to the reference on a stable key, computes displacement in a metric projection, and writes a lookup from match level to metres with its provenance.samplergeocodedreferencelookupstratified draw by settlement × match leveljoin on the stable identifierreference points + stated accuracyproject to metres, compute displacementmedian and p95 per match level, with provenanceThe provenance — provider version, reference vintage, sample seed — is what makes next year’s measurement comparable with this one.
A displacement figure without its provenance cannot be compared with the next one, which removes most of its value.

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.

Displacement by match level: median and 95th percentileBar chart of 95th-percentile displacement in metres by match level: rooftop 6 metres, parcel 18, interpolated 74, street 210 and postcode 1,450, with medians far lower in every case.rooftopp95 6 m · median 2.4 mparcelp95 18 m · median 7 minterpolatedp95 74 m · median 21 mstreetp95 210 m · median 58 mpostcodep95 1,450 m · median 320 mDistributions are heavily right-skewed, so the median describes the typical case and the 95th percentile describes the risk. Quote both.
Medians look reassuring and the tail is what breaks an analysis — which is why fitness gates are set on the percentile.

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.

Sampling decisions that change the answerGrid of four sampling decisions with the wrong approach and the right one: where the sample is drawn from, how strata are chosen, how many records per stratum, and how repeat measurements are compared.WrongRightSample framegeocoded outputthe input address listStratanone, or by volumesettlement type × match levelPer stratumproportional to volumea floor of ~30, target 60Repeat runsa fresh random samplethe same seed, so change is realSampling from the output silently excludes everything that failed to geocode, which is the population most likely to be badly positioned.
Three of these four mistakes bias the result optimistically, which is the direction nobody questions.

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

Back to Geocoding and Address Data Validation