Calculating Geocoding Confidence Scores in Python

TL;DR: A robust geocoding confidence score is a weighted composite of three signals — match type (50%), component-match completeness (30%), and the provider’s own normalized score (20%) — blended into a single 0-100 value you can threshold. This page gives a typed Python function and a pandas column; it expands the scoring step of validating geocoding accuracy and confidence scoring.

Why a Composite Score Beats a Single Field

A provider’s raw confidence answers “how sure am I this is the right record?” but ignores two things you care about: how precise the returned geometry is, and whether the result actually contains the components you searched for. A relevance of 0.95 on a result that echoes back only a city — dropping the street and house number you supplied — is a confident city centroid, not a confident rooftop. A composite score fixes this by combining a precision signal, a completeness signal, and the provider signal so that no single flattering field can carry a bad result over the line.

The three components are deliberately orthogonal:

  • Match type captures geometry precision (rooftop vs interpolated vs centroid).
  • Component completeness captures how much of the requested address the result reproduces.
  • Provider score captures the vendor’s own record-match confidence.

From Score to Action

A score that does not change behaviour is decoration. Three bands cover the useful responses, and their boundaries should be set from the cost of each error rather than from round numbers — the review band exists precisely because some records are worth a human minute and most are not.

Score bands and the action each triggers A horizontal score axis from zero to one, divided into three bands. Below 0.55 records are rejected and returned to source, about 3 percent of volume. Between 0.55 and 0.85 records go to human review, about 6 percent. Above 0.85 records are accepted automatically, about 91 percent. reject return to source with a reason code · ~3% review queue for a human · ~6% accept write through · ~91% 0.0 0.55 0.85 1.0 Set the upper boundary from the cost of a wrong acceptance and the lower one from the cost of a wrong rejection. A delivery pipeline and a marketing mailing have very different answers, and the same score serves both if the boundaries are configuration rather than constants in the scorer.

Size the review band against the review capacity you actually have. A band that produces six percent of a million-row batch is sixty thousand records, which is not a queue anyone will clear — either the band narrows, or the review is sampled and the rest are accepted with the band recorded.

Score Weight Breakdown

Each component is computed on a 0-1 scale, multiplied by its weight, summed, and scaled to 0-100. The weights below are tuned for delivery and rooftop-dependent workloads; lower the match-type weight for analytics that tolerate centroids.

Component Weight Signal it captures 1.0 means 0.0 means
Match type 0.50 Geometry precision Rooftop / exact house match Region or country centroid
Component completeness 0.30 Requested fields returned House, street, city, postcode all echoed Only a locality returned
Provider score 0.20 Vendor record-match confidence Provider score 1.0 (rescaled) Provider score 0.0
Composite 1.00 Overall trust 100 0

A composite of 85+ is a safe automatic accept; 55–85 warrants a fallback or review; below 55 should be rejected. These cut-offs mirror the accept/fallback/reject tiers in the parent guide.

The Scoring Function

The function scores each component independently, then blends. match_type is looked up against a precision table, completeness is the fraction of requested components present in the response, and the provider score is clamped to 0-1.

from __future__ import annotations

from dataclasses import dataclass
from typing import Mapping, Optional, Sequence

# Match-type precision on a 0-1 scale (provider-neutral vocabulary).
_MATCH_TYPE_SCORE: dict[str, float] = {
    "rooftop": 1.0,
    "exact": 1.0,
    "range_interpolated": 0.8,
    "street": 0.6,
    "geometric_center": 0.4,
    "postcode": 0.35,
    "locality": 0.2,
    "approximate": 0.15,
    "region": 0.1,
}

_W_MATCH = 0.50
_W_COMPLETENESS = 0.30
_W_PROVIDER = 0.20


@dataclass
class ScoredResult:
    composite: int          # 0-100
    match_component: float   # 0-1
    completeness: float      # 0-1
    provider_component: float  # 0-1


def _completeness(
    requested: Sequence[str],
    returned: Mapping[str, Optional[str]],
) -> float:
    """Fraction of requested components the result actually echoes back."""
    if not requested:
        return 1.0
    present = sum(
        1 for c in requested
        if returned.get(c) not in (None, "", "null")
    )
    return present / len(requested)


def confidence_score(
    match_type: str,
    requested_components: Sequence[str],
    returned_components: Mapping[str, Optional[str]],
    provider_score: Optional[float],
) -> ScoredResult:
    """Compute a weighted composite geocoding confidence score (0-100).

    Args:
        match_type:           Provider-neutral precision label (e.g. 'rooftop').
        requested_components: Components supplied in the query, e.g.
                              ['house', 'street', 'city', 'postcode'].
        returned_components:  Mapping of component name to returned value.
        provider_score:       Provider-native confidence in [0, 1], or None.

    Returns:
        A ScoredResult with the composite and its three sub-scores.

    Raises:
        ValueError: If provider_score is outside [0, 1].
    """
    match_component = _MATCH_TYPE_SCORE.get(match_type.lower(), 0.0)

    completeness = _completeness(requested_components, returned_components)

    if provider_score is None:
        # No native score: lean on match type as the provider proxy.
        provider_component = match_component
    else:
        if not 0.0 <= provider_score <= 1.0:
            raise ValueError(f"provider_score out of range: {provider_score}")
        provider_component = float(provider_score)

    composite01 = (
        _W_MATCH * match_component
        + _W_COMPLETENESS * completeness
        + _W_PROVIDER * provider_component
    )
    return ScoredResult(
        composite=round(composite01 * 100),
        match_component=round(match_component, 3),
        completeness=round(completeness, 3),
        provider_component=round(provider_component, 3),
    )

A worked example — a rooftop match that returns every requested component with a strong provider score:

scored = confidence_score(
    match_type="rooftop",
    requested_components=["house", "street", "city", "postcode"],
    returned_components={
        "house": "221B", "street": "Baker St",
        "city": "London", "postcode": "NW1 6XE",
    },
    provider_score=0.93,
)
print(scored.composite)  # 99

And a flattering-but-shallow result — high provider score but only a city echoed back:

scored = confidence_score(
    match_type="locality",
    requested_components=["house", "street", "city", "postcode"],
    returned_components={"city": "London"},
    provider_score=0.95,
)
print(scored.composite)  # 36  -> correctly rejected

The 0.95 provider score cannot rescue a locality-level match that dropped three of four requested components. That is exactly the failure a single-field threshold misses.

Keeping the Score Explainable

Storing only the final number makes every later question unanswerable. Persist the inputs alongside it, and both debugging and re-tuning become offline exercises over existing rows rather than a new production experiment.

What to store beside the confidence score A stored record with six fields: the composite score, the four contributing components, the version of the weight set used, and the answering provider. A note explains that this makes threshold changes replayable against historical rows without re-calling any provider. Stored field What it makes possible later score routing, reporting, SLAs contrib_provider, _tier, … explains a low score without re-running anything weights_version compares scores computed under different rules provider detects a per-provider calibration drift With these columns, "what would raising the threshold to 0.9 have done last quarter?" is a SQL query, not a project.

The weights_version column prevents a subtle reporting error. When the weights change, scores computed before and after are not comparable, and a dashboard that averages across the change shows a step that looks like a quality event and is an accounting artefact.

Vectorized pandas Column

For batch scoring, compute each component as a vectorized column and blend. Completeness is derived from per-component presence flags you materialize once during parsing.

import numpy as np
import pandas as pd

_MATCH_TYPE_MAP = pd.Series(_MATCH_TYPE_SCORE)


def score_frame(
    df: pd.DataFrame,
    match_type_col: str = "match_type",
    provider_col: str = "provider_score",
    completeness_col: str = "completeness",
) -> pd.DataFrame:
    """Add a 0-100 'confidence' column to a DataFrame of geocoding results.

    Expects a precomputed 'completeness' column in [0, 1] (fraction of
    requested components returned) and a provider score in [0, 1].
    """
    out = df.copy()

    match = (
        out[match_type_col].str.lower().map(_MATCH_TYPE_MAP).fillna(0.0)
    )
    provider = pd.to_numeric(out[provider_col], errors="coerce")
    completeness = pd.to_numeric(out[completeness_col], errors="coerce").fillna(0.0)

    # Where provider score is missing, use match type as the proxy.
    provider = provider.fillna(match).clip(0.0, 1.0)

    composite = (
        _W_MATCH * match
        + _W_COMPLETENESS * completeness
        + _W_PROVIDER * provider
    )
    out["confidence"] = (composite * 100).round().astype("Int64")
    out["verdict"] = np.select(
        [out["confidence"] >= 85, out["confidence"] >= 55],
        ["accept", "fallback"],
        default="reject",
    )
    return out

Materialize completeness upstream while you still have the raw response, since it needs both the requested and returned component sets — reconstructing it after flattening to columns is error-prone.

Where Composite Scoring Misleads

A composite is an average, and averages hide bimodality. Two very different records can score identically — one mediocre on everything, one excellent on three inputs and failing the fourth — and the second is frequently the dangerous one. Guard the components that must not fail with a floor rather than a weight.

Two records, same score, very different risk Two component profiles that both produce a composite score of 0.72. The first record scores moderately on all four inputs. The second scores highly on three and near zero on the geometric plausibility check, meaning the coordinate is in the wrong place. A note recommends a hard floor on critical components. Both compose to 0.72 record A uniformly mediocre — safe to review record B geometry fails Record B's coordinate is in the wrong country and its composite still clears a 0.7 threshold. Add a hard floor: any component below its own minimum sends the record to review regardless of the total. Floors are cheap; the failure they catch is the one that reaches a customer.

Implement the floor as a separate check rather than by inflating the weight. Raising the geometric weight enough to sink record B also sinks records where the polygon data is merely incomplete, and you end up trading a rare dangerous error for a common annoying one. A floor addresses only the case it is meant to.

Edge Cases

Provider returns extra components you did not request

Completeness measures requested-vs-returned, so extra components are ignored by design. Do not credit a result for returning a state you never asked for — it inflates the score without improving the match.

Missing provider score for the whole batch

Google Maps exposes no numeric confidence, only location_type. The function substitutes the match-type component as the provider proxy so Google results are not unfairly zeroed. Keep the substitution explicit rather than defaulting provider_score to 0.0, which would cap every Google composite at 80.

Integration Note

This composite score is the numeric input to the accept/fallback/reject routing described in validating geocoding accuracy and confidence scoring. Pair it with a geometric sanity check: even a composite of 95 should be re-examined if the point sits far from its expected reference, which is where detecting geocoding outliers with haversine distance comes in. Together, a high composite plus a small reference distance is the strongest signal that a geocode is safe to commit.