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.
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.
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.
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.
Related jump to heading
- Parent topic: Address Geocoding & Parcel Matching
- Section overview: Automated Feed Ingestion & GIS Data Parsing
- Matching permit addresses to parcels with fuzzy and spatial joins — the join whose output this validates
- CRS Alignment Strategies — every distance here is meaningless without a projected CRS
- Spatial Overlay Analysis — containment predicates and their edge cases