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.

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.

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.

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.