Building a Ground-Truth Set for Map-Matching Evaluation

Ground truth is the part of validation that teams postpone, because the obvious way to get it — driving planned routes with survey equipment — is expensive enough to need a budget conversation. The result is a matcher that has never been measured.

There is a much cheaper route that gets most of the value. High-rate traces matched generously and reviewed are good enough labels for evaluating the same drives at the fleet’s real sampling rate, and they cost almost nothing per trace. This page sets out that workflow, the review step that makes it trustworthy, and the stratification that stops the resulting set from measuring the easy majority. It supplies the labels the validation topic assumes.


Compatibility and Configuration Requirements

Requirement Value Notes
Source traces 1 Hz, ideally with Doppler speed The label quality is bounded by the source rate
Coverage Every network type and vehicle class in the fleet A set drawn from one depot measures one depot
Two matchers Differently configured, or two engines Needed for review-by-exception
Graph version Pinned and recorded per label Edge identifiers are meaningless without it
Storage Versioned, append-only Labels are expensive; never overwrite one in place

The Labelling Pipeline

from __future__ import annotations

from dataclasses import dataclass, asdict


@dataclass(frozen=True)
class Label:
    trace_id: str
    edges: tuple[tuple[int, int], ...]     # (osm_way_id, direction)
    graph_version: str
    source: str                            # "downsampled" | "instrumented" | "manual"
    reviewed_by: str | None
    network_class: str                     # rural | suburban | urban_grid | dense_centre
    source_rate_hz: float


def candidate_label(trace, matcher_a, matcher_b) -> tuple[Label | None, str]:
    """Match with two configurations; agreement becomes a label, disagreement goes to review."""
    a = matcher_a.match(trace)
    b = matcher_b.match(trace)
    if normalise(a.edges) == normalise(b.edges):
        return Label(
            trace_id=trace.id,
            edges=tuple(normalise(a.edges)),
            graph_version=matcher_a.graph_version,
            source="downsampled",
            reviewed_by=None,
            network_class=classify_network(a),
            source_rate_hz=trace.rate_hz,
        ), "auto"
    return None, "review"

The two-matcher agreement test is what makes this affordable. On a clean 1 Hz corpus two differently-configured matchers agree on ninety to ninety-five percent of traces, and those can be accepted without human time. The remaining five to ten percent are, almost by definition, the interesting ones — parallel carriageways, complex junctions, tunnels — and they are exactly where a reviewer’s attention is worth spending.

Review by exception Twelve hundred high-rate traces enter the pipeline. Two differently configured matchers agree on 1 090 of them, which are accepted automatically. The remaining 110 go to a review queue, of which 74 are confirmed, 29 are corrected and 7 are rejected as unlabellable. 1 200 traces in, 1 193 labels out, 110 human decisions 1 200 traces 1 Hz, all depots two matchers agree? yes no 1 090 accepted automatically no human time 110 reviewed 74 confirmed · 29 corrected 7 rejected as unlabellable The 29 corrections are the most valuable rows in the set: they are cases both matchers got wrong. Keep the 7 rejects too, flagged — a trace nobody can label is a finding about the data, not a gap.

Stratifying the Set

An unstratified set inherits the fleet’s own bias. If eighty percent of driving is suburban, eighty percent of the labels will be suburban, and the benchmark will report an accuracy that is essentially the suburban figure — hiding the dense-centre performance that generates most of the complaints.

Sample deliberately instead. A workable target for a mixed fleet:

Stratum Share of set Reason
Dense centre 30 % Highest failure rate, most complaints
Urban grid 25 % Large share of driving, moderate difficulty
Suburban 25 % The bulk of distance
Rural / motorway 20 % Easy, but regressions here are still regressions

Then cross that with the sampling rates the fleet actually runs. One reviewed 1 Hz label yields four evaluation traces — 1 Hz, 10 s, 30 s, 60 s — at no extra labelling cost, which makes sampling rate the cheapest axis in the whole exercise to measure.

def downsample_variants(trace, label: Label, rates_s=(1, 10, 30, 60)) -> list[tuple]:
    """One reviewed label, several evaluation traces."""
    return [(trace.every(n), label) for n in rates_s]

Execution and Tuning Guidelines

Record provenance on every label. Which source, which reviewer, which graph version, which date. A label with no provenance cannot be re-examined when the benchmark disagrees with production, and that disagreement is the moment the provenance is needed.

Never overwrite a label. Append a new one and mark the old superseded. Labels are the most expensive artefact in the whole validation exercise, and an accidental overwrite during a re-labelling pass destroys work nobody can reconstruct.

Grow the set from production failures. Every trace that a customer complained about, or that an engineer investigated, should end up in the set. This is what keeps the benchmark representative without a periodic re-labelling project — and it is why the set should be append-only.

Re-resolve labels when the graph moves. Edge identifiers are graph-specific. When the benchmark moves to a newer graph, re-resolve each label’s edges and put any that fail resolution back into the review queue rather than dropping them.

What stratification reveals The same benchmark reported two ways. Unstratified, correct-edge rate is 93 percent, which sounds healthy. Stratified, rural is 98 percent, suburban 95, urban grid 91 and dense centre 78 — and the dense centre is where the complaints come from. Same benchmark, reported two ways unstratified 93 % rural 98 % suburban 95 % urban grid 91 % dense centre 78 % The headline figure is an average of four populations, and it describes none of them.

Common Pitfalls Specific to This Technique

Labelling with the same matcher and configuration you are evaluating. The benchmark then measures self-consistency, and every change that makes the matcher more confidently wrong scores as an improvement. The two configurations used for labelling must differ from the one under test.

Treating the review queue as a backlog. Unreviewed disagreements are where the information is. A queue that grows unboundedly means the set is quietly becoming an easy-cases set, which is the failure mode the whole exercise exists to avoid.

Losing the source trace. Keep the raw high-rate recording alongside the label. Without it, a future evaluation at a different sampling rate has to start over, and any question about how the label was derived is unanswerable.


Keeping the Set Alive

A ground-truth set decays in two ways, and both are quiet.

The map moves underneath it. Edge identifiers are graph-specific, and a way that gets split or retagged upstream changes identity. Six months of OSM edits typically invalidates two to five percent of labels in an active urban area. The failure is not an error — the label simply stops matching anything, and the trace silently drops out of the benchmark.

The fleet moves away from it. New depots, new vehicle classes, a change in the sampling-rate mix. The set keeps measuring the fleet as it was when the set was built, and its verdicts drift further from production experience each quarter.

Both are managed the same way: re-resolve on every graph change, and grow the set continuously from production.

def revalidate(labels: list[Label], graph) -> tuple[list[Label], list[Label]]:
    """Split labels into those that still resolve against `graph` and those that do not."""
    live, stale = [], []
    for lab in labels:
        if all(graph.has_edge(way, direction) for way, direction in lab.edges):
            live.append(lab)
        else:
            stale.append(lab)
    return live, stale

Stale labels go back to the review queue, not to the bin. Most of them need only a re-resolution against the new geometry; a few reveal that the road genuinely changed, and those are worth knowing about independently of the benchmark.

Growth from production

The cheapest source of new labels is the work the team already does. Every production trace that somebody investigated — because a customer complained, because a distance looked wrong, because an alert fired — has been effectively hand-reviewed already. Capturing the reviewed edge sequence at that moment costs a few seconds and adds precisely the kind of case a synthetic set will never contain.

Set a target for the mix rather than for the size. A set that is roughly a fifth investigated production cases stays honest; one that is entirely downsampled recordings drifts toward measuring the easy majority, however large it grows.

A set that is maintained rather than merely large Label counts over six quarters. Stale labels appear at each graph refresh and are worked back into the live set, while new labels arrive continuously from investigated production cases. The live total grows steadily and the stale backlog never accumulates. Label counts over six quarters 1 600 0 live labels stale — spikes at each graph refresh, worked down Q1 Q6 A stale line that trends upward is a set nobody is re-resolving, and its benchmark is quietly shrinking. Growth here comes almost entirely from investigations the team was doing anyway.