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 valuesheight, 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 valuessurface, 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.

Why a standard-deviation rule fails on OSM numeric tags Three panels. Building heights are heavily skewed, with the great majority between three and twenty metres and a long tail reaching three hundred, so a mean plus three standard deviations threshold lands far above any plausible error and catches nothing. Removing the tall buildings to fix that discards exactly the valid data the check should preserve. A median and median absolute deviation computed within a building category resists the tail entirely, keeps skyscrapers valid as skyscrapers, and still flags a house tagged as fifteen hundred metres. Skew, and what survives it Mean and SD Tail inflates the SD Threshold lands too high Catches nothing useful Worse as data grows Trim the tall ones Threshold comes down Discards valid data Skyscrapers now errors Trades one failure for two Median and MAD Tail does not move it Computed per category Skyscrapers stay valid House at 1500m flagged The middle panel is the common instinct and it turns a detection problem into a data-loss problem.
Robust statistics are not a refinement here; the naive version detects nothing at all.

Runnable solution Jump to heading

python
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

  1. 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.
  2. Normalise explicit units before comparing. A value of 12 ft is correctly tagged and must become metres before it enters the distribution, not be flagged for being small.
  3. 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.
  4. Keep the multiplier generous. This produces a review queue, not a build failure, and a tight band buries the interesting findings in plausible ones.
  5. Name the suspected unit. “1500 is implausible” is a finding; “1500 is implausible as metres but plausible as centimetres” is a fix.
  6. For categorical values, combine rarity with similarity. Either signal alone produces mostly false positives; together they are precise enough to act on.
  7. 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.
  8. Report counts alongside findings. Knowing a suspected typo accounts for eleven features out of four million sets the priority instantly.
Separating a typo from a genuinely rare tag value A decision with three branches applied to a rare categorical value. A value that is rare and not similar to any common value is most likely genuine, such as a niche surface material, and reporting it produces noise. A value that is rare and very similar to a much more common one is most likely a typo, such as surface equals aphalt beside surface equals asphalt, and the count ratio is what confirms it. A value that is similar to a common one but used thousands of times is a real variant that people deliberately use, such as a regional spelling, and belongs in the normalisation table rather than a defect queue. Rare, similar, or both How rare, and how similar? Rarity alone flags real values Similarity alone flags variants Rare, not similar to anything Probably genuine: a niche value that really exists Rare and near-identical to a common value Probably a typo: report it with both counts Similar but used thousands of times A real variant: add it to the normalisation table The third branch is the one that turns an outlier report into an improvement to the pipeline rather than an edit to the data.
Both signals are needed; each on its own produces mostly false positives.
Five outlier signatures and what each one usually means A grid of five observed patterns against the mistake each usually indicates and the action it calls for. A value roughly one hundred times too large in a length tag indicates centimetres recorded without a unit, and the action is to convert and review. A value roughly three times too large in a metre-denominated tag indicates feet recorded as metres. A value ten times too large indicates a misplaced decimal point, which needs a human because the correct magnitude is ambiguous. A rare categorical value near-identical to a common one indicates a typo, and the action is to normalise it in the mapping table. A rare categorical value close to nothing indicates a genuine niche value, and the action is to leave it alone. Signature, cause, action Usually means Action About 100x too large centimetres, no unit convert and review About 3x too large feet read as metres convert and review Exactly 10x too large misplaced decimal point needs a human Rare, near a common value a typo normalise in the table Rare, near nothing a genuine niche value leave it alone The third row is the only one where the correct value is genuinely ambiguous, and it is the only one that cannot be automated.
Naming the likely cause is what separates a review queue people work through from one they do not.

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 ft and 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 height key is documented as a value in metres, with an optional explicit unit suffix such as ft permitted; values without a unit are metres by convention. The maxspeed key defaults to kilometres per hour, with mph and knots available 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 for height, maxspeed and 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.

Up one level: Tag & Attribute Consistency Checks.