Fuzzy Name Matching for OSM POI Conflation Jump to heading
Compare “St. Mary’s C of E Primary School” with “SAINT MARYS CHURCH OF ENGLAND PRIMARY” and get a useful number — without also deciding that “Bar Vega” and “Bar Vela” are the same place.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Two different kinds of similarity measure exist and they fail on opposite things.
Character-edit measures — Levenshtein and its normalised forms — count the operations needed to turn one string into another. They handle typos and spelling variants well and word reordering badly: “Vega Bar” versus “Bar Vega” is a large edit distance despite being obviously the same name.
Token-set measures split both strings into words and compare the sets. They handle reordering, extra words and missing words well, and typos badly: a single wrong letter turns a matching token into a non-matching one and the whole word is lost.
Real place names need both, and blending them into one number discards the information that distinguishes the failure modes. Keeping them separate lets a pair with a high token score and a low character score — a reordered name — be treated differently from one with the reverse profile.
The third component is the normalisation that runs before either, and its guiding rule is that it must be conservative and reversible: it produces a comparison string, never replaces the original.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import re
import unicodedata
from dataclasses import dataclass
from rapidfuzz import fuzz
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.conflate.names")
# Generic words safe to drop: they carry no distinguishing content on their own.
# NOTE: words like "church", "school" or "hotel" are NOT here — for many places
# they are the whole distinguishing part of the name.
DROPPABLE = {
"ltd", "limited", "plc", "inc", "llc", "gmbh", "sa", "sp", "zoo",
"the", "and",
}
EXPANSIONS = {
"st": "saint", "ste": "sainte", "mt": "mount", "ft": "fort",
"rd": "road", "ave": "avenue", "sq": "square",
}
_PUNCT = re.compile(r"[^\w\s]", re.UNICODE)
_WS = re.compile(r"\s+")
@dataclass(frozen=True)
class NameScore:
token: float # 0..1, set-based; robust to reordering
char: float # 0..1, edit-based; robust to typos
comparable: bool # False when scripts differ or a side is empty
@property
def best(self) -> float:
return max(self.token, self.char) if self.comparable else 0.0
@property
def profile(self) -> str:
if not self.comparable:
return "incomparable"
if self.token - self.char > 0.25:
return "reordered"
if self.char - self.token > 0.25:
return "typo-like"
return "consistent"
def dominant_script(text: str) -> str:
"""Crude script detection: enough to avoid comparing across alphabets."""
for ch in text:
if ch.isalpha():
name = unicodedata.name(ch, "")
for script in ("LATIN", "CYRILLIC", "GREEK", "ARABIC", "HEBREW",
"HANGUL", "HIRAGANA", "KATAKANA", "CJK"):
if script in name:
return script
return "UNKNOWN"
def normalise(text: str) -> str:
"""Produce a comparison string. The caller keeps the original."""
# NFKC folds compatibility forms; casefold is stronger than lower().
text = unicodedata.normalize("NFKC", text).casefold()
text = _PUNCT.sub(" ", text)
tokens = [EXPANSIONS.get(t, t) for t in _WS.split(text) if t]
kept = [t for t in tokens if t not in DROPPABLE]
# If dropping emptied the name, keep the original tokens: a place genuinely
# called "The Limited" must not normalise to nothing.
return " ".join(kept or tokens)
def compare(left: str, right: str) -> NameScore:
if not left or not right:
return NameScore(0.0, 0.0, comparable=False)
if dominant_script(left) != dominant_script(right):
# Comparing across scripts produces meaningless numbers; say so instead.
return NameScore(0.0, 0.0, comparable=False)
a, b = normalise(left), normalise(right)
if not a or not b:
return NameScore(0.0, 0.0, comparable=False)
return NameScore(
token=fuzz.token_set_ratio(a, b) / 100.0,
char=fuzz.ratio(a, b) / 100.0,
comparable=True,
)
def osm_names(tags: dict[str, str]) -> list[str]:
"""Every name an OSM feature carries, not just the default one."""
keys = [k for k in tags if k == "name" or k.startswith("name:")
or k in {"alt_name", "official_name", "short_name", "old_name"}]
values: list[str] = []
for key in keys:
# Semicolon-separated multi-values are common in alt_name.
values.extend(v.strip() for v in tags[key].split(";") if v.strip())
return values
def best_against_osm(external: str, tags: dict[str, str]) -> NameScore:
"""Score an external name against every name the feature carries."""
scores = [compare(external, name) for name in osm_names(tags)]
comparable = [s for s in scores if s.comparable]
if not comparable:
return NameScore(0.0, 0.0, comparable=False)
return max(comparable, key=lambda s: s.best)
if __name__ == "__main__":
tags = {"name": "St. Mary's C of E Primary School",
"alt_name": "Saint Marys Primary"}
score = best_against_osm("SAINT MARYS CHURCH OF ENGLAND PRIMARY", tags)
logger.info("token=%.2f char=%.2f profile=%s",
score.token, score.char, score.profile)
Step-by-step walkthrough Jump to heading
- Keep the droppable list short and safe. Legal suffixes and a couple of articles only. Words like “church”, “school” and “hotel” look generic and are frequently the entire distinguishing content of a name.
- Expand rather than strip abbreviations. Turning “St” into “saint” makes it match “Saint”; deleting it makes both names shorter and less distinctive.
- Guard against emptying a name. A place called “The Limited” would normalise to nothing under a naive filter; falling back to the unfiltered tokens prevents that.
- Refuse cross-script comparisons. A Latin and a Cyrillic name produce a meaningless similarity number that is nevertheless a number, and it will be used. Returning an explicit incomparable result is far better than returning zero, which looks like evidence of a mismatch.
- Score against every name the feature carries. OSM features frequently carry
alt_name,official_name,short_nameand language variants, and the external dataset may use any of them. Comparing only againstnamethrows away the easiest matches. - Split semicolon multi-values.
alt_namein particular often holds several names separated by semicolons, as covered in Splitting Semicolon-Separated OSM Tag Values. - Return a profile, not just a number. Naming the shape of the difference — reordered, typo-like, consistent — is what makes an uncertain pair reviewable in seconds rather than minutes.
Verification Jump to heading
- Known pairs score high. Take twenty labelled true pairs and confirm the best score exceeds your intended threshold on nearly all of them.
- Known non-pairs score low. Twenty labelled false pairs, especially nearby similar businesses, should score well below it.
- Reordered names are caught. “Bar Vega” against “Vega Bar” should score high on token similarity and carry the reordered profile.
- Cross-script pairs are incomparable. A Latin name against a Cyrillic one must return the incomparable result, not zero.
- Alternative names are used. A feature whose
alt_namematches but whosenamedoes not should still produce a high score.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Distinct places score identically | Distinguishing word in the drop list | Remove generic-looking but meaningful words from it |
| A name normalises to nothing | Every token was droppable | Fall back to the unfiltered tokens |
| Reordered names score low | Only a character measure used | Add a token-set measure and keep both |
| Typos score low | Only a token measure used | Add a character measure and keep both |
| Cross-script pairs look like mismatches | Zero returned instead of incomparable | Detect the script and mark the pair incomparable |
| Easy matches missed | Only the default name compared | Compare against every name key the feature carries |
| Accented names never match | No Unicode normalisation | Apply compatibility normalisation before comparing |
Specification reference Jump to heading
The token set ratio compares two strings by splitting them into token sets and measuring similarity over the intersection and the differences, which makes it insensitive to word order and to extra tokens on either side. The simple ratio is based on the longest matching subsequence and is sensitive to character-level differences. See the RapidFuzz documentation for the exact definitions of each scorer and their normalisation to a zero-to-one hundred range.
Frequently Asked Questions Jump to heading
Should I strip generic words like "church" or "school"?
No. They look generic and for many places they are the entire distinguishing content of the name — a village with a church, a school and a pub may have three features whose names differ only in that word. Restrict the drop list to legal suffixes and a couple of articles, and expand abbreviations rather than deleting them. The risk of dropping a meaningful word is much larger than the gain from a slightly shorter comparison string.
Why keep two similarity scores instead of one combined number?
Because they fail on opposite things and the difference between them is diagnostic. A high token score with a low character score means the words match but were reordered; the reverse means the order is right but the spelling differs. A reviewer can judge those two cases in seconds, while a single blended number tells them only that something was partially similar.
How should I handle names in different scripts?
Mark the pair incomparable rather than scoring it. A similarity measure over two different alphabets returns a small number, and a small number looks like evidence that the names differ when in fact no comparison was possible. If your data genuinely spans scripts, transliterate deliberately into a common form as a separate, reviewable step — and keep both the original and the transliteration.
Which OSM name tags should I compare against?
All of them. A feature commonly carries a default name, one or more language variants, an official name, a short name and one or more alternative names, and an external dataset may use any of those. Comparing only the default name discards the easiest matches available. Split semicolon-separated multi-values as well, because alternative name tags frequently hold several names in one string.
Related Jump to heading
- Matching OSM Features to External Datasets — the parent topic and where this signal fits.
- Scoring Conflation Candidates with Multiple Signals — combining this with distance and category.
- Splitting Semicolon-Separated OSM Tag Values — handling multi-valued name tags properly.
- Value Standardization & Regex Cleaning — the same conservative normalisation discipline for other fields.
- Tag Taxonomy & Key-Value Standards — the name namespace this reads.
Up one level: Matching OSM Features to External Datasets.