Validating geocode results against parcel centroids

The match report says 96% confirmed and the permit for a downtown infill lot is attached to a ranch fourteen miles out of town. The geocoder returned a point, the point fell inside a parcel, the text agreed well enough, and nothing in the pipeline objected — because nothing asked whether the distance between the geocode and the parcel it matched was plausible. A fixed threshold does not solve it either: 400 metres is generous in a downtown block and absurdly tight on a quarter-section. This guide builds the distance test properly, scaled to the parcel it is testing, as the verification layer for address geocoding & parcel matching.

Diagnosis: what a distance test can and cannot see jump to heading

Compute the distance from each geocode to its matched parcel’s centroid and plot the distribution. Four populations show up, and they are separable.

A tight cluster near zero is the healthy population — address points and rooftop geocodes on lots whose centroid is close to their frontage.

Four populations in the distance distribution A four-row by three-column matrix describing the populations visible in a distance-to-centroid distribution. Rows are a tight cluster near zero, a broad shoulder to a few hundred metres, a second mode beyond a kilometre, and distances of exactly zero. Columns are the usual cause, whether the population is legitimate, and the correct action. The shoulder is legitimate and comes from large parcels whose centroid is far from the addressed frontage. The distant mode is mailing addresses or bad interpolations. Exact zeros usually mean the geocode is the parcel centroid, fed back from the same dataset. Four populations in the distance distribution usual cause legitimate? action Tight cluster near zero address points, rooftops yes confirm Shoulder out to a few hundred metres large or deep parcels yes use the ratio test Second mode beyond 1 km mailing address, bad interpolation no reject Distance exactly zero geocode IS the centroid proves nothing downgrade to probable healthy needs the ratio reject or downgrade
The shoulder is the population people wrongly tighten thresholds against: a flag lot's centroid can genuinely sit 200 m behind its address. The exact-zero row is the subtle one — it usually means the geocode is the centroid you are comparing it with, which proves nothing at all.

A broad shoulder out to a few hundred metres is mostly legitimate: large parcels whose centroid is genuinely far from the addressed frontage. A flag lot’s centroid can sit 200 metres behind its address.

A second mode at a kilometre or more is almost always mailing addresses — the owner’s home rather than the land — or an interpolated geocode that landed on the wrong block.

Distances at exactly zero are worth a separate look, because they often mean the geocode is the parcel centroid, fed back from the same dataset. That is not confirmation; it is the same number compared with itself.

def distance_report(matches, parcels_gdf):
    """Distance to centroid, normalised by parcel size, so a quarter-section and a
    downtown lot can be judged on the same axis."""
    rows = []
    for m in matches:
        if m.parcel_id is None:
            continue
        geom = parcels_gdf.loc[m.parcel_id].geometry
        d = geom.centroid.distance(Point(m.x, m.y))
        # The natural scale for "is this plausible?" is the parcel's own radius,
        # not a constant. A circle of equal area gives a robust radius even for
        # awkward shapes.
        radius = math.sqrt(geom.area / math.pi)
        rows.append({"parcel_id": m.parcel_id, "distance_m": d,
                     "radius_m": radius, "ratio": d / radius if radius else float("inf"),
                     "kind": m.geocode_kind, "tier": m.tier})
    return rows

The ratio column is what makes the test work across lot sizes: a geocode one parcel-radius from the centroid is near the boundary, and two radii out means it is outside the parcel entirely.

Step-by-step implementation jump to heading

1. Test containment first, distance second jump to heading

Containment is the stronger statement and it is cheap. Distance is what grades the near misses and catches the case where a point falls inside the wrong parcel.

A fixed threshold against one scaled to the parcel Line chart of the rejection threshold in metres against parcel area, from a 400 square metre urban lot to a 26 hectare quarter section. A fixed 400 metre threshold is a flat line. The ratio-based threshold of three equal-area radii rises from 34 metres on the smallest lot to 863 metres on the largest. The fixed threshold is thirty times too loose on the smallest parcels and half as tight as it should be on the largest. A fixed threshold against one scaled to the parcel 0 200 400 600 800 1000 400 m² 900 m² 1600 m² 4000 m² 1 ha 4 ha 26 ha Parcel area Rejection threshold (metres) fixed 400 m threshold 3 × equal-area radius
The two lines cross near 17 000 m². Below that a fixed 400 m threshold is far too loose — it accepts a geocode ten lots away downtown; above it, too tight, rejecting legitimate frontage points on large rural parcels. Scaling by the parcel's own equal-area radius puts every lot size on one axis.
INSIDE_OK_RATIO = 1.0        # inside the parcel: distance is informational only
NEAR_RATIO = 1.6             # just outside — frontage points legitimately do this
FAR_RATIO = 3.0              # beyond this the match is not credible


def validate(match, geom, geo, lot_radius) -> tuple[str, str]:
    pt = Point(geo.x, geo.y)
    d = geom.centroid.distance(pt)
    ratio = d / lot_radius if lot_radius else float("inf")

    if geom.contains(pt):
        if geo.kind in ("address_point", "rooftop", "parcel_centroid"):
            return "confirmed", f"inside|{geo.kind}|{ratio:.2f}r"
        return "probable", f"inside|{geo.kind}|{ratio:.2f}r"   # interpolated: inside by luck

    if ratio <= NEAR_RATIO and geo.kind in ("address_point", "rooftop"):
        # A frontage point on a deep lot is outside the centroid's radius and still
        # correct. Accept it as probable, never as confirmed.
        return "probable", f"outside|near|{ratio:.2f}r"

    if ratio >= FAR_RATIO:
        return "rejected", f"outside|far|{ratio:.2f}r"

    return "ambiguous", f"outside|{ratio:.2f}r|{geo.kind}"

Note that an interpolated geocode falling inside the parcel yields probable, not confirmed. It is inside by luck on a wide lot, and treating luck as evidence is what produced the fourteen-mile ranch: on that parcel, almost any point in the neighbourhood is inside it.

2. Guard against the self-comparison jump to heading

def is_self_referential(geo, geom, tol=0.5) -> bool:
    """True when the 'geocode' is the parcel centroid we are testing against —
    typically because the geocoder was built from the same parcel layer."""
    return geo.kind == "parcel_centroid" and geom.centroid.distance(Point(geo.x, geo.y)) < tol

A geocoder derived from the parcel layer cannot validate a match against that layer. Detect the case and downgrade to probable with an explicit reason, rather than recording a confirmation the data cannot support.

3. Use a frontage-aware distance where you have street geometry jump to heading

The centroid is a proxy for “where the parcel is.” Where a street centreline layer exists, the sharper test is the distance from the geocode to the parcel’s frontage — the part of its boundary nearest the addressed street.

def frontage_distance(geom, street_line, pt):
    """Distance from the geocode to the parcel's addressed edge. On deep or flag
    lots this is far more discriminating than the centroid distance, which is
    dominated by lot depth rather than by whether the point is on the right lot."""
    frontage = geom.boundary.intersection(street_line.buffer(12.0))
    if frontage.is_empty:
        return geom.centroid.distance(pt)
    return frontage.distance(pt)

4. Alert on the distribution, not the mean jump to heading

def batch_health(rows) -> dict:
    ratios = sorted(r["ratio"] for r in rows)
    n = len(ratios)
    return {
        "n": n,
        "median_ratio": ratios[n // 2] if n else None,
        "p95_ratio": ratios[int(n * 0.95)] if n else None,
        "share_rejected": sum(1 for r in rows if r["ratio"] >= FAR_RATIO) / n if n else 0,
        "share_interpolated": sum(1 for r in rows
                                  if r["kind"] not in ("address_point", "rooftop")) / n if n else 0,
    }

The two numbers worth alerting on are p95_ratio and share_interpolated. The mean hides everything: a batch with a healthy median and a heavy tail is the batch with the fourteen-mile ranch in it.

Two batches with the same match rate and different health Lollipop chart comparing four health metrics between two batches that both report a 96 per cent match rate. The healthy batch has a median ratio of 0.31, a 95th percentile of 1.4, 0.4 per cent rejected and 6 per cent interpolated. The unhealthy batch has a median of 0.44, a 95th percentile of 8.7, 5.2 per cent rejected and 61 per cent interpolated. The medians are close; the tail and the interpolated share are where the difference shows. Two batches with the same match rate and different health 0 20 40 60 80 limit healthy · median ratio 0.31 unhealthy · median ratio 0.44 healthy · p95 ratio 1.4 unhealthy · p95 ratio 8.7 healthy · % interpolated 6 unhealthy · % interpolated 61 Ratio (radii) or percent, as labelled Same match rate, same median; the tail and the interpolated share diverge.
Both batches report 96% matched. Their medians are close and their tails are not: a 95th percentile of 8.7 radii means one match in twenty is nowhere near its parcel, and a 61% interpolated share says the address-point layer stopped being available. Alert on those two, never on the mean.

Verification & testing jump to heading

def test_interpolated_inside_a_large_parcel_is_not_confirmed():
    quarter_section = box(0, 0, 800, 800)             # ~64 ha
    geo = Geocode(x=400, y=400, kind="interpolated")
    tier, why = validate(m, quarter_section, geo, lot_radius=451)
    assert tier == "probable" and "interpolated" in why


def test_frontage_point_on_a_deep_lot_is_accepted():
    deep = box(0, 0, 20, 200)                          # 20 m wide, 200 m deep
    geo = Geocode(x=10, y=2, kind="address_point")      # at the street edge
    tier, _ = validate(m, deep, geo, lot_radius=math.sqrt(4000 / math.pi))
    assert tier in ("confirmed", "probable")           # never rejected


def test_mailing_address_is_rejected():
    lot = box(0, 0, 30, 40)
    geo = Geocode(x=9000, y=14000, kind="rooftop")     # the owner's house, miles away
    tier, why = validate(m, lot, geo, lot_radius=19.5)
    assert tier == "rejected" and "far" in why


def test_self_referential_geocode_is_detected():
    lot = box(0, 0, 30, 40)
    c = lot.centroid
    assert is_self_referential(Geocode(c.x, c.y, "parcel_centroid"), lot)

The deep-lot test is the one that stops somebody “fixing” the threshold downward: a legitimate frontage point on a 200-metre lot is more than one centroid-radius away, and a tighter rule rejects correct matches.

Failure recovery jump to heading

A batch validated with a fixed threshold. Re-validate with the ratio test and diff the tiers. Rural batches will gain confirmations that the fixed threshold rejected; urban batches will lose confirmations that it wrongly accepted. Both movements are corrections.

The geocoder is derived from the parcel layer. Every confirmation it produced is a self-comparison. Downgrade them all to probable and find an independent source for the addresses that matter — usually the county address-point layer, which is independent because the assessor assigned it.

Rejections that are actually correct matches. Look for a frontage pattern: if the rejected parcels are all deep, flag, or through lots, the centroid proxy is the problem and the frontage distance is the fix. If they are scattered, the geocoder is interpolating and the fix is upstream.

Frequently asked questions jump to heading

Why scale the threshold by parcel size instead of using a fixed distance?

Because a fixed distance is simultaneously too tight for rural parcels and too loose for urban ones. Four hundred metres rejects legitimate frontage points on a quarter-section and accepts a point two blocks away downtown. Dividing by the parcel’s equal-area radius puts both on the same axis: one radius is roughly the boundary, and three radii is not credible whatever the lot size.

If the point is inside the parcel, is the match confirmed?

Only if the geocode kind is precise. On a large parcel almost any point in the vicinity falls inside it, so containment from an interpolated geocode is luck rather than evidence. Containment plus an address-point or rooftop result is a confirmation; containment alone is probable.

What does a distance of exactly zero mean?

Usually that the geocode is the parcel centroid, because the geocoder was built from the same parcel layer you are validating against. That is a self-comparison and proves nothing, so detect it explicitly and downgrade the tier. A genuine independent geocode landing exactly on a centroid is vanishingly rare.

Should the frontage distance replace the centroid distance everywhere?

Where a reliable street centreline layer exists, yes — it is strictly more discriminating, because centroid distance on a deep lot is dominated by lot depth rather than by whether the point is on the right lot. Keep the centroid version as the fallback, since the frontage intersection is empty for parcels with no mapped street adjacency.