Scoring Conflation Candidates with Multiple Signals Jump to heading
Turn four separate pieces of evidence about a candidate pair into a decision a reviewer can argue with — which means keeping the evidence, not replacing it with a number.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Four signals carry most conflation evidence, and their usefulness depends on being independent and on missingness being handled honestly.
A signal is missing when the evidence simply is not there: an OSM feature with no name, an external record with no category, a pair where the two names are in different scripts. The wrong response is to score it zero, because zero means “strong evidence against” and missing means “no evidence either way”. The right response is to renormalise over the signals that are present, so a pair evidenced by distance and category alone is judged on those two rather than penalised for lacking a name.
The second idea is that the best score alone is not a decision. A pair scoring 0.85 with the next candidate at 0.30 is a different situation from one scoring 0.85 with the next at 0.83, and only the first is confident. The gap is a second number, and using both is what separates a matcher that works in dense areas from one that does not.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from enum import Enum
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.conflate.score")
class Outcome(str, Enum):
MATCH = "match"
REVIEW = "review"
NO_MATCH = "no_match"
@dataclass(frozen=True)
class Signals:
"""Each field is a score in 0..1, or None when there is NO evidence.
None is not zero: zero means 'the evidence argues against', None means
'no evidence either way'. Conflating the two penalises features for
lacking a tag, which is not the same as contradicting one.
"""
distance: float | None = None
name: float | None = None
category: float | None = None
identifier: float | None = None
WEIGHTS: dict[str, float] = {
"distance": 0.20, "name": 0.45, "category": 0.20, "identifier": 0.15,
}
MATCH_FLOOR = 0.78
REVIEW_FLOOR = 0.45
MIN_GAP = 0.15
MIN_EVIDENCE_WEIGHT = 0.5 # refuse to decide on too little evidence
@dataclass(frozen=True)
class Scored:
total: float
present_weight: float
signals: Signals
components: dict[str, float] = field(default_factory=dict)
def distance_score(metres: float, radius_m: float) -> float:
"""1 at zero separation, decaying smoothly to 0 at the candidate radius."""
if metres >= radius_m:
return 0.0
# A cosine taper is gentler near zero than a linear ramp, which matters
# because positional error is concentrated near the true position.
return 0.5 * (1.0 + math.cos(math.pi * metres / radius_m))
def combine(signals: Signals) -> Scored:
"""Weighted mean over the signals that are PRESENT, renormalised."""
components: dict[str, float] = {}
total = 0.0
present = 0.0
for name, weight in WEIGHTS.items():
value = getattr(signals, name)
if value is None:
continue
components[name] = value
total += weight * value
present += weight
if present == 0.0:
return Scored(0.0, 0.0, signals, components)
return Scored(total / present, present, signals, components)
def classify(ranked: list[Scored]) -> tuple[Outcome, str]:
"""Decide from the best score, the runner-up gap and the evidence weight."""
if not ranked:
return Outcome.NO_MATCH, "no candidates"
best = ranked[0]
runner_up = ranked[1].total if len(ranked) > 1 else 0.0
gap = best.total - runner_up
if best.identifier_match():
return Outcome.MATCH, "shared identifier"
if best.present_weight < MIN_EVIDENCE_WEIGHT:
return Outcome.REVIEW, f"only {best.present_weight:.2f} of evidence present"
if best.total < REVIEW_FLOOR:
return Outcome.NO_MATCH, f"best score {best.total:.2f} below the floor"
if best.total >= MATCH_FLOOR and gap >= MIN_GAP:
return Outcome.MATCH, f"score {best.total:.2f}, gap {gap:.2f}"
if best.total >= MATCH_FLOOR:
return Outcome.REVIEW, f"score {best.total:.2f} but gap only {gap:.2f}"
return Outcome.REVIEW, f"score {best.total:.2f} in the middle band"
def _identifier_match(self: Scored) -> bool:
return self.signals.identifier is not None and self.signals.identifier >= 0.99
Scored.identifier_match = _identifier_match # small helper, kept out of the data
def score_record(candidates: list[Signals]) -> tuple[Outcome, str, list[Scored]]:
ranked = sorted((combine(s) for s in candidates),
key=lambda s: (-s.total, -s.present_weight))
outcome, reason = classify(ranked)
logger.info("%s: %s (components %s)", outcome.value, reason,
ranked[0].components if ranked else {})
return outcome, reason, ranked
if __name__ == "__main__":
near = Signals(distance=distance_score(18.0, 120.0), name=0.91,
category=1.0)
far = Signals(distance=distance_score(95.0, 120.0), name=0.44,
category=0.0)
score_record([near, far])
Step-by-step walkthrough Jump to heading
- Model absent evidence as
None. The distinction between “no name on this feature” and “the names disagree completely” is the single most important modelling decision in the scorer, and collapsing it is why matchers under-match sparsely tagged features. - Renormalise over present signals. Dividing by the weight actually available means a pair with two of four signals is judged on those two at full strength rather than scoring at most half.
- Track how much evidence was present. A high score computed from one weak signal is not the same as the same score from three; the evidence weight makes that visible and drives an explicit abstain.
- Taper distance smoothly. A cosine taper is close to flat near zero, which reflects that positional error clusters near the true position, and falls to zero exactly at the candidate radius so no candidate outside it can contribute.
- Short-circuit on a shared identifier. A matching reference code or Wikidata link is near-conclusive and should not be diluted by a mediocre name score.
- Abstain on thin evidence. Below a minimum evidence weight the scorer routes to review regardless of the number, because the number is not meaningful.
- Require a gap as well as a score. A high score with a close runner-up is ambiguity, not confidence, and it is exactly the dense-area case that ruins precision.
- Return the components and a reason. The reason string is what a reviewer reads first, and the components are what they check when the reason surprises them.
Verification Jump to heading
- Missing signals do not penalise. Score a pair with a perfect name and no category; it should score close to a pair with a perfect name and a matching category, not half of it.
- The taper reaches zero at the radius. A candidate at exactly the radius must contribute nothing.
- Thin evidence abstains. A pair evidenced only by distance should route to review however close it is.
- The gap check fires. Construct two candidates scoring 0.86 and 0.84; the classifier must say review, not match.
- Calibration holds on the sample. Run the labelled sample through and confirm precision and recall match what the thresholds intend, per Measuring Conflation Precision and Recall.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Unnamed features never match | Missing name scored as zero | Model absent evidence as none and renormalise |
| Scores cluster near the weights’ midpoint | Missing signals dragging every total down | Divide by the present weight, not the total weight |
| Wrong matches in dense areas | Only the best score consulted | Require a minimum gap to the runner-up |
| A single signal decides everything | Weights never calibrated | Fit weights against a labelled sample per source |
| Confident matches on one weak signal | No evidence-weight floor | Abstain below a minimum present weight |
| Identifier matches sent to review | Checks applied in the wrong order | Short-circuit on a shared identifier first |
| Reviewers cannot tell why | Only the total returned | Return the components and a human-readable reason |
Specification reference Jump to heading
Weighted scoring over partially observed evidence is conventionally handled by renormalising the weights over the observed subset, so that an unobserved signal neither contributes nor penalises. This differs from imputing a neutral or zero value, which biases the result towards the imputed value. The distinction matters in OpenStreetMap conflation because tag presence varies enormously between regions and feature classes; see Tag Taxonomy & Key-Value Standards for why absence is so common.
Frequently Asked Questions Jump to heading
Why is a missing signal not the same as a zero score?
Because zero means the evidence argues against a match and missing means there is no evidence either way. An OSM feature with no name tag has not disagreed with your external record’s name; it has simply said nothing. Scoring that as zero penalises sparsely tagged features — which are disproportionately the ones in areas where mapping is thinner — and systematically under-matches exactly where you most need the match.
How should I choose the weights?
Fit them against a labelled sample for each source rather than adopting a set from elsewhere. The relative value of name similarity and category agreement depends entirely on whether the external dataset has good names and whether its categories map onto OSM tagging at all. A few hundred labelled pairs is enough to see which signals separate true from false pairs, and that separation is what the weights should reflect.
Why require a gap to the runner-up?
Because a high score with a near-identical alternative means the matcher is choosing between two equally plausible answers on no real basis. That situation is common in dense areas — a row of similar shops, a campus of similar buildings — and it is precisely where an absolute threshold alone produces confident wrong answers. The gap turns those cases into review items instead.
Should the scorer ever abstain?
Yes, and explicitly. When too little evidence is present the computed number is not meaningful however high it is, and pretending otherwise is how a matcher produces confident matches from a single weak signal. An explicit abstain, routed to review with a reason naming the missing evidence, is both more honest and more useful than a number nobody can interpret.
Related Jump to heading
- Matching OSM Features to External Datasets — the parent topic and the three-way classification.
- Fuzzy Name Matching for OSM POI Conflation — producing the strongest signal here.
- Measuring Conflation Precision and Recall — calibrating these thresholds against evidence.
- Linking OSM Features to Wikidata Identifiers — establishing the identifier signal deliberately.
- Authoring OSM Validation Rules — the same false-positive discipline applied to rules.
Up one level: Matching OSM Features to External Datasets.