Detecting Centroid Fallback in Geocoded Results
Fallback is the geocoder doing its best with an address it could not resolve: it returns the centre of the smallest thing it could resolve, and the result is a coordinate that looks exactly like a real one. The damage is not the individual error — it is that hundreds of records collapse onto a single point, producing spikes in density surfaces and systematically biasing every distance measured from them. This guide finds those collapsed points using three independent signatures, quantifies how much of the layer is affected, and annotates the records so the exclusion decision can be made per analysis. It implements the fallback detection referenced in Geocoding and Address Data Validation.
Prerequisites
- geopandas 0.14+, pandas 2.x, shapely 2.0+ and a projected CRS in metres for the distance work.
- The geocoded layer with its metadata columns — match level and confidence at minimum, as described in the parent topic. The detection works without them, but the diagnosis is far weaker.
- Administrative geometries for the levels your geocoder falls back to: postcode or postal-sector polygons, locality boundaries and, if available, street centrelines. These are used to identify fallback level, not to detect the collapse itself.
- A duplicate-address expectation. Multi-occupancy buildings legitimately share a coordinate, so you need at least a rough sense of how common that is in your data before setting a clustering threshold.
Step-by-Step Procedure
Step 1 — Cluster coincident coordinates
# fallback/step1_cluster.py
import geopandas as gpd
import pandas as pd
def coincidence_table(gdf: gpd.GeoDataFrame, precision: int = 6) -> pd.DataFrame:
"""Records grouped by exact coordinate, most crowded first."""
key = list(zip(gdf.geometry.x.round(precision), gdf.geometry.y.round(precision)))
frame = gdf.assign(_key=key)
grouped = frame.groupby("_key").agg(
record_count=("_key", "size"),
match_levels=("match_level", lambda s: sorted(set(s))),
example=("input_address", "first"),
)
grouped = grouped.reset_index()
grouped[["lon", "lat"]] = pd.DataFrame(grouped["_key"].tolist(), index=grouped.index)
return grouped.drop(columns="_key").sort_values("record_count", ascending=False)
def choose_threshold(table: pd.DataFrame) -> int:
"""Pick a clustering threshold from the shape of the distribution."""
counts = table["record_count"].value_counts().sort_index()
# The duplicate-address tail decays smoothly; fallback shows up as a heavy upper tail.
typical = counts.index[counts.index <= 4].max() if (counts.index <= 4).any() else 1
return int(max(5, typical + 1))
Verification: print the top twenty rows. Fallback clusters stand out immediately — dozens or hundreds of records on one coordinate, usually with a single non-rooftop match level. A cluster of three flats in one building looks completely different.
Step 2 — Identify which centroid each cluster sits on
# fallback/step2_identify.py
import geopandas as gpd
from shapely.geometry import Point
def label_clusters(clusters: gpd.GeoDataFrame, layers: dict[str, gpd.GeoDataFrame],
tolerance_m: float = 5.0, metric_epsg: int = 27700) -> gpd.GeoDataFrame:
"""Match each suspect coordinate to the centroid of a containing admin geometry."""
out = clusters.to_crs(metric_epsg).copy()
out["fallback_level"] = None
out["fallback_distance_m"] = None
for level, layer in layers.items():
cand = layer.to_crs(metric_epsg).copy()
cand["centroid"] = cand.geometry.representative_point()
for idx, row in out[out["fallback_level"].isna()].iterrows():
pt: Point = row.geometry
near = cand[cand.geometry.contains(pt)]
if near.empty:
continue
d = near["centroid"].distance(pt).min()
if d <= tolerance_m:
out.at[idx, "fallback_level"] = level
out.at[idx, "fallback_distance_m"] = round(float(d), 2)
return out
Verification: run with layers={"postcode": pc, "locality": loc} and check that labelled clusters have a fallback_distance_m close to zero. A cluster inside a postcode but tens of metres from its centroid is not a postcode fallback — it may be a street centroid or a genuine shared premises, and mislabelling it overstates the problem.
Step 3 — Look for coordinate rounding
Some providers return low-precision coordinates for low-precision matches, which is a second, independent signature.
# fallback/step3_rounding.py
import numpy as np
import pandas as pd
def rounding_signature(gdf) -> pd.DataFrame:
"""Decimal places actually used per record — a proxy for reference precision."""
def places(v: float) -> int:
s = f"{v:.8f}".rstrip("0")
return len(s.split(".")[1]) if "." in s else 0
dp = gdf.geometry.apply(lambda g: min(places(g.x), places(g.y)))
summary = (pd.DataFrame({"match_level": gdf["match_level"], "decimals": dp})
.groupby("match_level")["decimals"]
.agg(["count", "median", "min", "max"]))
summary["suspect_coarse"] = summary["median"] <= 3 # ~100 m in degrees
return summary
Verification: rooftop matches should use six or more decimals. A match level whose median is three decimals is returning positions quantised to roughly a hundred metres, regardless of what the label claims.
Step 4 — Quantify the affected share
# fallback/step4_quantify.py
def fallback_summary(gdf, flagged_keys: set) -> dict:
total = len(gdf)
key = list(zip(gdf.geometry.x.round(6), gdf.geometry.y.round(6)))
affected = sum(1 for k in key if k in flagged_keys)
by_level = (gdf.assign(_flag=[k in flagged_keys for k in key])
.groupby("match_level")["_flag"].agg(["size", "sum"]))
return {
"records": total,
"fallback_records": affected,
"fallback_share": round(affected / total, 4) if total else 0.0,
"distinct_fallback_points": len(flagged_keys),
"by_match_level": {
level: {"records": int(r["size"]), "fallback": int(r["sum"]),
"share": round(r["sum"] / r["size"], 4) if r["size"] else 0.0}
for level, r in by_level.iterrows()
},
}
Verification: the by_match_level breakdown should show fallback concentrated in the coarser levels. Fallback appearing inside a rooftop bucket is the serious case — it means the provider’s label cannot be used as a filter, and every downstream gate built on it is ineffective.
Step 5 — Annotate the layer instead of deleting rows
# fallback/step5_annotate.py
import geopandas as gpd
def annotate(gdf: gpd.GeoDataFrame, labelled_clusters: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Attach a fallback flag and inferred level to every record."""
lookup = {
(round(r.lon, 6), round(r.lat, 6)): (r.fallback_level, r.record_count)
for r in labelled_clusters.itertuples()
}
keys = list(zip(gdf.geometry.x.round(6), gdf.geometry.y.round(6)))
gdf = gdf.copy()
gdf["fallback_flag"] = [k in lookup for k in keys]
gdf["fallback_level"] = [lookup.get(k, (None, None))[0] for k in keys]
gdf["cluster_size"] = [lookup.get(k, (None, 1))[1] for k in keys]
return gdf
Verification: re-run any downstream density analysis with and without fallback_flag == False filtering. The difference in the resulting surface is the distortion the fallback was contributing, and it is usually larger than people expect.
Interpreting Results
The three signatures answer different questions and disagree usefully:
- Coincidence clustering finds the collapse without any reference data. It is the most reliable detector and the least informative diagnosis.
- Centroid matching names the level, which converts the finding into an expected error magnitude — a postcode centroid in a dense urban area is a few hundred metres wrong; the same in a rural area can be several kilometres.
- Coordinate rounding catches a subtler case: results that are not literally coincident but are quantised, which produces the same clustering effect at coarse scales and passes a naive coincidence test.
When all three agree, the finding is unambiguous. When clustering fires but centroid matching does not, look for a shared premises, a delivery depot, or a data-entry default such as a head-office address applied to many records — all real defects, but not geocoder fallback, and each with a different owner.
The number to publish is the fallback share per match level, alongside the count of distinct fallback points. A layer with 4% fallback spread across two thousand points is a different problem from 4% concentrated on twelve points: the second creates twelve artificial hotspots, and those are the ones that end up in a briefing slide.
Gotchas & Edge Cases
Rounding your own coordinates creates false clusters. Storing geocoded results as five-decimal values quantises them to about a metre, which merges genuinely distinct nearby points. Keep full precision in storage and round only inside the detection function.
Multi-occupancy is not fallback. A block of forty flats sharing one entrance coordinate is correct data. The distinguishing signal is the match level and the address components: forty records with distinct unit identifiers at a rooftop match is fine; forty records with unrelated street names is not.
Some providers jitter fallback points deliberately. A few geocoders scatter unresolvable results within the containing polygon to avoid exactly this clustering. That defeats coincidence detection and, worse, makes the results look better than they are. Detect it by comparing the spatial distribution against the match level: a cloud of postcode-level matches uniformly filling a postcode area is jittered fallback.
Street-level matches are interpolations, not centroids. They will not sit on a centroid and will not cluster, yet they can still be tens of metres out. Fallback detection does not find them — that is what the displacement measurement in the sibling guide is for.
A cluster on a national grid origin or at (0, 0) is a different bug. Records at null island are failed geocodes written as zeros rather than nulls. Treat them as missing data and fix the writer, as they will otherwise corrupt every bounding box computed from the layer.
When to Escalate
- Fallback appearing in rooftop-labelled results — escalate to the provider immediately. This invalidates match-level gating across the entire programme, not just this dataset.
- Fallback share above the fitness threshold for the intended analysis: the layer is not fit for that use, and the decision belongs with the data steward under the accountability model in Assigning Spatial Data Ownership with a RACI Matrix.
- Concentrated fallback in a specific area usually means the reference data has a coverage gap there. That is worth reporting upstream, because it will not resolve itself and it affects every consumer of the same reference file.
- Jittered fallback — a provider hiding unresolvable matches inside a polygon needs a contractual conversation, since no downstream check can reliably distinguish jittered fallback from real positions.
Frequently Asked Questions
How many coincident records prove a fallback?
Genuine coincidence exists — flats in one building, multiple accounts at one premises — so a threshold of two is useless. In practice, five or more records sharing a coordinate to six decimal places is worth inspecting, and twenty or more is almost always a fallback. Set the threshold from the data: plot the distribution of records-per-coordinate and the fallback cluster separates visibly from the duplicate-address tail.
Can fallback be detected without administrative boundaries?
Yes, partially. Coincidence clustering finds the collapsed points with no reference data at all, and the coordinate-rounding signature needs nothing either. What boundaries add is the inferred level — knowing whether a cluster sits at a postcode centre or a locality centre tells you how far wrong the affected records are, which is what a fitness decision needs.
Should fallback records be deleted from the layer?
No. Delete them and every rate computed from the layer changes its denominator without explanation. Flag them, keep them, and let each analysis decide: a regional count may legitimately include postcode-level matches, while a walking-distance calculation must not. The flag makes that decision explicit and auditable instead of silent.
Why does fallback matter more than random positional error?
Random error averages out in aggregate statistics; fallback does not. It is systematic and spatially structured — every unresolvable address in an area moves to the same point, creating a spike where none exists and emptying the surrounding area. Kernel density surfaces, nearest-facility assignments and hotspot tests are all badly distorted by it, while being fairly robust to random error of similar magnitude.
Related
- Geocoding and Address Data Validation — match levels, confidence and the fitness gate
- Measuring Geocoding Accuracy Against Reference Points — turning match levels into metres
- Validating Address Normalization Before Geocoding — reducing the fallback rate at source