Finding Statistical Outliers in OSM Tag Values Jump to heading
A building with height=1500 is almost certainly metres confused with centimetres, and a maxspeed=500 is almost certainly a decimal point in the wrong place — neither is invalid, and both will happily reach a consumer.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Tag values come in two kinds and need different machinery.
Numeric values — height, maxspeed, width, level, capacity — have distributions, and the distributions are skewed and heavy-tailed. Most buildings are a few metres high; a few are three hundred. A mean-and-standard-deviation rule either flags every genuine skyscraper or nothing at all, because the tall buildings inflate the standard deviation that is supposed to catch them. The workable approach is the same robust machinery used for build thresholds — median and median absolute deviation — applied within a category, because a building=house and a building=skyscraper are not samples from the same distribution.
Categorical values — surface, amenity, cuisine — have frequency distributions with a very long tail, and the tail contains both genuine rare values and typos. surface=aphalt occurring eleven times against surface=asphalt occurring four million is a typo; surface=woodchips occurring eleven times is a real thing. Frequency alone cannot distinguish them; frequency plus edit distance to a common value usually can.
The unit confusion case deserves its own treatment because it is both common and detectable exactly. A value that is implausible as metres but entirely plausible as centimetres or feet is not a random outlier, it is a specific mistake, and reporting the suspected unit alongside the value is what makes the finding actionable rather than merely noticed.
Runnable solution Jump to heading
from __future__ import annotations
import difflib
import logging
import re
import statistics
from collections import Counter, defaultdict
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.qa.outliers")
MIN_SAMPLE = 200 # below this a distribution means little
NUMERIC_K = 6.0 # generous: this is a review queue, not a gate
MIN_RARE = 50 # a value below this count may be a typo
TYPO_RATIO = 0.001 # ...if it is this much rarer than its neighbour
UNIT_SUFFIX = re.compile(r"^\s*(-?\d+(?:\.\d+)?)\s*([a-z']*)\s*$", re.I)
# Plausible ranges in the tag's DOCUMENTED unit, used to name the mistake.
PLAUSIBLE = {"height": (0.5, 900.0), "width": (0.3, 100.0),
"maxspeed": (5.0, 200.0), "capacity": (1.0, 20_000.0)}
UNIT_FACTORS = {"cm": 0.01, "mm": 0.001, "ft": 0.3048, "'": 0.3048,
"km": 1000.0, "mph": 1.609344}
@dataclass(frozen=True)
class Outlier:
osm_id: int
key: str
value: str
category: str
reason: str
suspected: str | None = None
def parse_number(raw: str) -> tuple[float, str] | None:
match = UNIT_SUFFIX.match(raw)
if not match:
return None
try:
return float(match.group(1)), match.group(2).lower()
except ValueError:
return None
def suspect_unit(key: str, value: float) -> str | None:
"""An implausible metre value that is plausible in another unit is not a
random outlier; it is a specific, nameable mistake."""
low, high = PLAUSIBLE.get(key, (float("-inf"), float("inf")))
if low <= value <= high:
return None
for unit, factor in UNIT_FACTORS.items():
if low <= value * factor <= high:
return f"plausible if the value is {unit}: {value * factor:,.2f}"
return None
def robust_spread(values: Sequence[float]) -> float:
centre = statistics.median(values)
return statistics.median([abs(v - centre) for v in values]) * 1.4826
def numeric_outliers(features: Iterable[tuple[int, Mapping[str, str]]],
key: str, category_key: str) -> list[Outlier]:
"""Group by category first: a house and a skyscraper are different
distributions, and pooling them hides both ends."""
groups: dict[str, list[tuple[int, float, str]]] = defaultdict(list)
for osm_id, tags in features:
raw = tags.get(key)
if raw is None:
continue
parsed = parse_number(raw)
if parsed is None:
continue
value, unit = parsed
if unit in UNIT_FACTORS: # explicit units are not errors
value *= UNIT_FACTORS[unit]
groups[tags.get(category_key, "unknown")].append((osm_id, value, raw))
found: list[Outlier] = []
for category, rows in groups.items():
if len(rows) < MIN_SAMPLE:
continue
values = [v for _, v, _ in rows]
centre = statistics.median(values)
spread = robust_spread(values) or max(abs(centre) * 0.05, 0.1)
low, high = centre - NUMERIC_K * spread, centre + NUMERIC_K * spread
for osm_id, value, raw in rows:
if low <= value <= high:
continue
found.append(Outlier(
osm_id, key, raw, category,
f"{value:,.2f} against a {category} median of {centre:,.2f} "
f"(band {low:,.2f} to {high:,.2f})",
suspect_unit(key, value)))
logger.info("%s: %d numeric outlier(s)", key, len(found))
return found
def categorical_outliers(features: Iterable[tuple[int, Mapping[str, str]]],
key: str) -> list[Outlier]:
"""Rarity alone cannot separate a typo from a genuinely rare value.
Rarity plus closeness to a common value usually can."""
counts = Counter(tags[key] for _, tags in features if key in tags)
common = [v for v, n in counts.items() if n >= MIN_SAMPLE]
total = sum(counts.values()) or 1
suspicious: dict[str, str] = {}
for value, n in counts.items():
if n >= MIN_RARE:
continue
near = difflib.get_close_matches(value, common, n=1, cutoff=0.86)
if near and n / max(counts[near[0]], 1) < TYPO_RATIO:
suspicious[value] = near[0]
found = [Outlier(osm_id, key, tags[key], "-",
f"{counts[tags[key]]} use(s) against "
f"{counts[suspicious[tags[key]]]:,} for a near-identical value",
f"probably {suspicious[tags[key]]!r}")
for osm_id, tags in features
if tags.get(key) in suspicious]
logger.info("%s: %d suspected typo(s) across %d distinct value(s), "
"%.4f%% of uses", key, len(found), len(suspicious),
100 * sum(counts[v] for v in suspicious) / total)
return found
if __name__ == "__main__":
logger.info("robust bands per category; rarity plus edit distance for text")
Step-by-step walkthrough Jump to heading
- Group numeric checks by category. Pooling houses with towers produces a band wide enough to admit anything and narrow enough to flag legitimate tall buildings, depending on the mix.
- Normalise explicit units before comparing. A value of
12 ftis correctly tagged and must become metres before it enters the distribution, not be flagged for being small. - Use the median and median absolute deviation. The distributions are skewed and heavy-tailed, and the naive statistics are moved by exactly the values they should be detecting against.
- Keep the multiplier generous. This produces a review queue, not a build failure, and a tight band buries the interesting findings in plausible ones.
- Name the suspected unit. “1500 is implausible” is a finding; “1500 is implausible as metres but plausible as centimetres” is a fix.
- For categorical values, combine rarity with similarity. Either signal alone produces mostly false positives; together they are precise enough to act on.
- Require a large frequency ratio. A value used a thousand times is a real thing even if it is close to something more common, and the ratio is what encodes that.
- Report counts alongside findings. Knowing a suspected typo accounts for eleven features out of four million sets the priority instantly.
Verification Jump to heading
- A planted unit error is found. Set a building height to 1500 and confirm the finding names centimetres.
- Skyscrapers are not flagged. Confirm genuinely tall buildings in the tall-building category pass.
- Explicit units pass. Tag a width as
12 ftand confirm it is normalised rather than reported. - A planted typo is found. Introduce a near-miss spelling at low frequency and confirm it is matched to its common neighbour.
- Rare genuine values are not. Confirm a legitimate uncommon value with no close neighbour produces nothing.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| No numeric outliers ever found | Mean and standard deviation on a skewed tail | Use the median and median absolute deviation |
| Tall buildings flagged as errors | One distribution for all building types | Group the distribution by category |
| Correctly tagged imperial values flagged | Units not normalised before comparison | Parse and convert the suffix first |
| Hundreds of false typo reports | Similarity used without a frequency ratio | Require the rare value to be far rarer |
| Real rare values reported as typos | Frequency used without similarity | Require a close match among common values |
| Findings nobody can act on | Value reported without the suspected cause | Name the suspected unit or spelling |
| Small categories produce nonsense | Distribution derived from a handful of rows | Require a minimum sample per category |
Specification reference Jump to heading
The
heightkey is documented as a value in metres, with an optional explicit unit suffix such asftpermitted; values without a unit are metres by convention. Themaxspeedkey defaults to kilometres per hour, withmphandknotsavailable as explicit suffixes. Consumers are expected to treat an absent unit as the documented default rather than inferring one from magnitude. See the OpenStreetMap wiki pages forheight,maxspeedand units.
Frequently Asked Questions Jump to heading
Should outliers be corrected automatically?
Numeric unit confusions are tempting because the correction is usually obvious, and it is still worth routing them through review — some of those values are simply wrong rather than mis-united, and a confident automated conversion turns an obvious error into a plausible one. Categorical typos are safer to normalise automatically in your own pipeline, where the mapping table makes the decision visible and reversible, but that is different from editing the upstream data.
How do you choose the category to group numeric values by?
By whatever makes the distribution unimodal. For heights that is usually the building value; for speeds it is the highway class; for widths it is a combination of highway and whether the way is in an urban area. The test is empirical: plot the distribution within a candidate grouping and see whether it has one hump. If it has two, the grouping is hiding a distinction that matters.
What about values that are outliers because the area is unusual?
This is the main source of false positives at continental scale, because a distribution computed across a continent does not describe any particular place in it. Computing bands per region as well as per category is the fix, and it needs enough features per region to be meaningful, which limits how finely you can slice. Where that is not possible, the finding should carry the region so a reviewer can apply local knowledge quickly.
Is edit distance the right similarity measure for tag values?
It works well for typos, which are what this check targets, and poorly for genuine alternatives that happen to be spelled differently. Keyboard-adjacency weighting improves it slightly; phonetic matching is worse, because tag values are not words people pronounce. The practical answer is that the simple ratio with a high cutoff catches most real typos, and the cases it misses are better handled by an explicit normalisation table than by a cleverer metric.
Related Jump to heading
- Tag & Attribute Consistency Checks — the parent topic.
- Setting Quality Thresholds That Fail a Build — the same robust statistics across runs rather than within one.
- Normalizing OSM Yes/No Tag Values — where confirmed variants should end up.
- Splitting Semicolon-Separated OSM Tag Values — a structural cause of apparently rare values.
- Generating an OSM Data Quality Report — presenting a review queue of candidates.
Up one level: Tag & Attribute Consistency Checks.