Measuring Map Matching Accuracy with Hausdorff and F1

The validation topic argues that map matching needs several metrics because there are several independent ways to be wrong. This page is the implementation: four metrics, what each one sees, and the ways each can be quietly misleading.

All four assume a matched output and a ground truth expressed the same way — an ordered sequence of directed edge identifiers, plus the geometry those edges resolve to. Comparing internal engine edge IDs is a trap covered at the end.


Compatibility and Configuration Requirements

Requirement Value Notes
shapely ≥ 2.0 hausdorff_distance with a densify argument
CRS Projected, metres Hausdorff in degrees is a latitude-dependent number
Edge identifiers OSM way id + direction Internal engine IDs are not comparable across builds
Edge lengths Available per edge Needed to weight F1 by distance rather than by count
Timestamps Present on both sequences Required only for the temporal metric

The Metrics

from __future__ import annotations

import numpy as np
import shapely
from shapely.geometry import LineString


def hausdorff(matched: LineString, truth: LineString, densify: float = 0.05) -> float:
    """Symmetric Hausdorff distance in metres. A worst-case measure."""
    return max(
        shapely.hausdorff_distance(matched, truth, densify=densify),
        shapely.hausdorff_distance(truth, matched, densify=densify),
    )


def directed_percentile(matched: LineString, truth: LineString, q: float = 95.0, step_m: float = 5.0) -> float:
    """Distance from truth to the matched path at the q-th percentile of sample points."""
    n = max(int(truth.length // step_m), 2)
    pts = [truth.interpolate(d) for d in np.linspace(0, truth.length, n)]
    d = np.array([matched.distance(p) for p in pts])
    return float(np.percentile(d, q))


def edge_scores(matched: list[tuple[int, int]], truth: list[tuple[int, int]],
                lengths: dict[tuple[int, int], float]) -> dict[str, float]:
    """Length-weighted precision, recall, F1 and correct-edge rate over directed edges."""
    m, t = set(matched), set(truth)
    inter_len = sum(lengths[e] for e in (m & t))
    m_len = sum(lengths[e] for e in m) or 1.0
    t_len = sum(lengths[e] for e in t) or 1.0
    precision = inter_len / m_len
    recall = inter_len / t_len
    f1 = 0.0 if precision + recall == 0 else 2 * precision * recall / (precision + recall)
    return {"precision": precision, "recall": recall, "f1": f1, "correct_edge_rate": recall}


def temporal_error(matched_offsets: np.ndarray, truth_offsets: np.ndarray,
                   speeds_mps: np.ndarray) -> dict[str, float]:
    """Along-path offset error converted to seconds using the local speed."""
    delta_m = np.abs(matched_offsets - truth_offsets)
    seconds = delta_m / np.clip(speeds_mps, 0.5, None)
    return {"median_s": float(np.median(seconds)), "p95_s": float(np.percentile(seconds, 95))}

Two implementation details are worth stating. hausdorff_distance without the densify argument compares only the vertices, so two paths that agree at every vertex and diverge between them score zero — which is exactly what happens when the matched path uses a simplified geometry. And edge_scores uses directed edges: an edge traversed the wrong way is a different element, which is what makes the metric able to see a reversal at all.

Maximum against percentile A matched path follows the ground truth closely for most of its length and diverges once around a junction. The maximum Hausdorff distance reports 84 metres, describing that single divergence. The 95th-percentile directed distance reports 9 metres, describing the rest of the path. One localised divergence, two very different numbers 84 m — the maximum 9 m — the 95th percentile ground truth matched Report both. A large ratio between them says "one bad junction"; a small ratio says "wrong the whole way". Gating on the maximum alone makes every benchmark run hostage to its single worst trace. Densify before computing Hausdorff, or two paths agreeing only at their vertices will score a perfect zero. Five metres is a reasonable densification step for road geometry; smaller costs time and changes nothing.

Execution and Tuning Guidelines

Weight edge scores by length, always. An urban trace crosses dozens of tiny junction links, and counting edges makes the metric mostly about those. Weighting by length makes it about driving.

Report precision and recall separately as well as F1. They fail differently and the difference is diagnostic: low recall with high precision means the matcher is dropping segments, usually at gaps; low precision with high recall means it is adding segments, usually detours through parallel roads.

Convert temporal error to seconds, not metres. An along-path offset of eighty metres is trivial on a motorway and enormous in a depot yard. Dividing by the local speed makes the number comparable across the fleet, which is the only way an aggregate is meaningful.

Clip the speed divisor. The np.clip(speeds_mps, 0.5, None) above prevents a stationary period from producing an infinite temporal error. Without it, one stop makes the whole trace’s p95 useless.

Precision and recall say different things Two failure modes plotted on precision against recall. A matcher that drops segments across gaps sits at high precision and low recall. A matcher that adds detours through parallel roads sits at low precision and high recall. The F1 score is similar for both, and tells you nothing about which is happening. Same F1, opposite problems 1.0 0.5 0.5 1.0 recall precision drops segments at gaps precision 0.96 · recall 0.63 · F1 0.76 adds parallel detours precision 0.64 · recall 0.95 · F1 0.76 Two matchers with identical F1 need opposite fixes — one needs gap handling, the other needs a tighter candidate radius.

Comparing Across Engines

Every engine has its own internal edge identifiers, and they change between builds of the same engine. Comparing on them measures the identifier scheme.

Resolve both sides to OSM way id plus a direction flag before scoring. Every engine can emit the source way for a matched edge, and direction comes from whether the traversal runs with or against the way’s node order. A small resolution layer per engine costs an afternoon and makes every subsequent comparison meaningful.

def normalise(edges: list[dict]) -> list[tuple[int, int]]:
    """(osm_way_id, +1 forward / -1 reverse) — comparable across engines and builds."""
    return [(e["osm_way_id"], 1 if e["forward"] else -1) for e in edges]

One caveat: a single OSM way often carries several engine edges, because engines split ways at junctions. Normalising collapses those, which slightly coarsens the metric — a matcher that takes the right way but the wrong half of it scores as correct. For fleet work that is usually acceptable, and where it is not, keep the split point in the key as well.


Common Pitfalls Specific to This Technique

Computing Hausdorff on the simplified display geometry. The simplified line is a few metres from the real one by construction, so the metric measures the simplification. Always score against the analytical geometry, per Douglas-Peucker simplification.

Scoring undirected edges and then wondering why reversals are invisible. Direction has to be in the key. It is one extra field and it is the only thing that makes a whole class of failure visible.

Averaging per-trace metrics without weighting. A fleet average over traces treats a four-kilometre delivery and a four-hundred-kilometre trunk run equally. Weight by distance when aggregating, or report the distribution rather than the mean.


Aggregating Across a Corpus

Per-trace metrics have to be combined before they mean anything at fleet scale, and the combination rule changes the answer more than most people expect.

Weight by distance, not by trace. A corpus containing four-kilometre delivery rounds and four-hundred-kilometre trunk runs will, under an unweighted mean, be dominated by the short traces simply because there are more of them. Weighting by matched distance gives a figure that means “the share of driving attributed to the right road”, which is what a consumer of the number assumes it already means.

def aggregate(rows: list[dict]) -> dict[str, float]:
    """Distance-weighted aggregate of per-trace metrics."""
    total = sum(r["truth_length_m"] for r in rows) or 1.0
    return {
        "correct_edge_rate": sum(r["correct_edge_rate"] * r["truth_length_m"] for r in rows) / total,
        "hausdorff_p95_m": float(np.percentile([r["hausdorff_m"] for r in rows], 95)),
        "temporal_p95_s": float(np.percentile([r["temporal_p95_s"] for r in rows], 95)),
        "traces": len(rows),
        "distance_km": total / 1000,
    }

Aggregate distances as percentiles, not as means. Hausdorff and temporal error both have long right tails, so a mean is pulled around by a handful of traces and moves noisily between runs. The 95th percentile is stable and answers a more useful question — how bad is it for the worst one trace in twenty.

Report the corpus size alongside every figure. A stratum of thirty traces has a standard error of several points, and a number quoted without its denominator invites comparisons it cannot support.

Confidence intervals, briefly

For a distance-weighted rate, a bootstrap over traces is the least troublesome way to get an interval: resample traces with replacement a thousand times, recompute the weighted rate, and take the 2.5th and 97.5th percentiles. It handles the weighting and the correlation within a trace without any distributional assumption, and it runs in under a second on a few hundred traces.

The interval matters most when comparing two configurations. A difference of half a point between configurations whose intervals are ±1.2 points is not a difference, and reporting it as one is how a team ends up chasing noise for a sprint.

Unweighted against distance-weighted aggregation A corpus of 180 short urban traces and 20 long trunk runs. The unweighted mean correct-edge rate is 0.86, dominated by the urban majority. Weighted by distance it is 0.94, because the trunk runs carry most of the kilometres and match nearly perfectly. 180 urban traces (4 km each) + 20 trunk runs (400 km each) unweighted mean 0.86 distance-weighted 0.94 urban share of traces 90 % urban share of distance 8 % Neither figure is wrong; they answer different questions, and only one of them is what people assume. Report both, or report the weighted one and say so — never report an unweighted mean as "accuracy".