Evaluating map-matching accuracy against ground truth
“96% of fixes matched to the right link” and “12% of route length is wrong” can both be true of the same run, because errors concentrate on short links at junctions. The metric that matters for most consumers is length-weighted route mismatch; per-fix accuracy is easier to compute, flatters the matcher, and hides exactly the failures that break turn counts and corridor exposure.
Why this happens
Fixes are distributed by time and links are distributed by length, so a per-fix metric weights a 900 m motorway link by the twenty fixes on it and a 25 m slip road by one. A matcher that gets every long link right and every junction wrong scores extremely well per fix and produces routes that are topologically nonsense — which is what a turn-count or corridor-exposure product actually consumes.
The two metrics also disagree about what an error is. Per-fix accuracy treats each fix independently, so a matcher that flips between two parallel links every other fix scores 50% while producing a route that no vehicle could drive. Route-based metrics penalise that properly, because the route is evaluated as a sequence. The matcher this evaluates is set out in map-matching to road networks.
Core pipeline
- Assemble ground truth from instrumented runs, high-rate reference traces, or manual annotation of a sample.
- Match the reference at full rate, then downsample the input to the production cadence and match again.
- Score by length, using the symmetric difference between the true and matched link sequences.
- Report per road class, because the aggregate hides where the matcher fails.
Production-ready Python implementation
import numpy as np
import pandas as pd
def route_mismatch(
truth_links: list,
matched_links: list,
link_lengths: dict,
) -> dict:
"""
Length-weighted route error between a true and a matched link sequence.
Parameters
----------
truth_links, matched_links : list
Ordered link ids. Order is used for the topology check; the length
metric itself is set-based, because a route that visits the right
links in the wrong order is a separate failure worth naming.
link_lengths : dict
link id -> length in METRES.
Returns
-------
dict
mismatch_frac, missed_m, extra_m, and a topology flag.
Raises
------
ValueError
On an empty truth route or a link with no recorded length.
"""
if not truth_links:
raise ValueError("Empty ground-truth route.")
unknown = (set(truth_links) | set(matched_links)) - set(link_lengths)
if unknown:
raise ValueError(f"{len(unknown)} link(s) have no length: {list(unknown)[:3]}")
t, m = set(truth_links), set(matched_links)
truth_m = sum(link_lengths[l] for l in t)
missed = sum(link_lengths[l] for l in t - m) # true links not matched
extra = sum(link_lengths[l] for l in m - t) # matched links not driven
# Symmetric difference over the true length: the standard route-mismatch
# fraction. It can exceed 1.0 when the matcher invents a long detour,
# which is correct — that is a worse outcome than matching nothing.
return {
"mismatch_frac": (missed + extra) / truth_m,
"missed_m": missed,
"extra_m": extra,
"truth_m": truth_m,
"order_differs": [l for l in matched_links if l in t] != [l for l in truth_links if l in m],
}
def evaluate_matcher(
runs: pd.DataFrame,
link_lengths: dict,
road_class: dict | None = None,
) -> pd.DataFrame:
"""
Score every labelled run and break the result down by road class.
runs : DataFrame with columns run_id, truth_links, matched_links (lists).
An aggregate score hides the class where the matcher fails, and that
class is almost always the one with the shortest links.
"""
required = {"run_id", "truth_links", "matched_links"}
missing = required - set(runs.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if runs.empty:
raise ValueError("No labelled runs to evaluate.")
rows = []
for _, r in runs.iterrows():
res = route_mismatch(r["truth_links"], r["matched_links"], link_lengths)
res["run_id"] = r["run_id"]
rows.append(res)
per_run = pd.DataFrame(rows)
if road_class is None:
return per_run
# Class-level breakdown, weighted by true length in that class.
cls_rows = []
for _, r in runs.iterrows():
t, m = set(r["truth_links"]), set(r["matched_links"])
for cls in set(road_class.get(l, "unknown") for l in t):
t_cls = {l for l in t if road_class.get(l, "unknown") == cls}
m_cls = {l for l in m if road_class.get(l, "unknown") == cls}
length = sum(link_lengths[l] for l in t_cls)
wrong = sum(link_lengths[l] for l in (t_cls - m_cls) | (m_cls - t_cls))
cls_rows.append({"road_class": cls, "truth_m": length, "wrong_m": wrong})
by_class = (pd.DataFrame(cls_rows).groupby("road_class", as_index=False)
.sum(numeric_only=True))
by_class["mismatch_frac"] = by_class["wrong_m"] / by_class["truth_m"]
return by_class.sort_values("mismatch_frac", ascending=False)
Validation block
def validate_evaluation(per_run: pd.DataFrame, by_class: pd.DataFrame,
target_frac: float = 0.05) -> dict:
"""Check the evaluation itself before believing its verdict."""
# 1. Enough runs to say anything. Below ~20 the confidence interval on a
# 5% mismatch rate is wider than the difference between two matchers.
assert len(per_run) >= 20, (
f"only {len(per_run)} labelled runs — the interval on the score is "
"wider than any difference you are trying to measure"
)
# 2. The aggregate is length-weighted, not a mean of per-run fractions.
# A mean of fractions over-weights short runs.
weighted = per_run["missed_m"].sum() + per_run["extra_m"].sum()
agg = weighted / per_run["truth_m"].sum()
naive = per_run["mismatch_frac"].mean()
if abs(agg - naive) > 0.02:
print(f"note: length-weighted {agg:.1%} vs mean-of-fractions "
f"{naive:.1%} — report the first")
# 3. Worst class must be reported, not buried.
worst = by_class.iloc[0]
assert worst["mismatch_frac"] < target_frac * 4, (
f"{worst['road_class']} is at {worst['mismatch_frac']:.1%} against an "
f"overall target of {target_frac:.0%} — the matcher fails on this class"
)
return {"aggregate_mismatch": float(agg), "worst_class": worst["road_class"],
"worst_frac": float(worst["mismatch_frac"]), "runs": len(per_run)}
GROUP BY.Common mistakes and gotchas
-
Reporting per-fix accuracy alone. It weights by time rather than by length and flatters a matcher that fails at junctions.
-
Averaging per-run fractions. Short runs then count as much as long ones. Aggregate by summing lengths.
-
Ground truth matched at the production cadence. The reference must be matched at full rate — otherwise it inherits the very errors you are measuring.
-
Evaluating only on the routes you tuned on. Hold out runs by area and by day, not by random fix.
-
Ignoring order. A matcher that visits the right links in an impossible order scores perfectly on a set-based metric. The
order_differsflag exists for that. -
No class breakdown. The aggregate almost always hides a class where the matcher is unusable, and it is usually the class the product depends on.
FAQ
How do I get ground truth without driving every road?
Three sources, in increasing cost. A high-rate reference trace matched at 1 Hz can serve as truth for evaluating the same trace downsampled — this measures cadence sensitivity, not absolute accuracy, and is nearly free. Manual annotation of a few dozen runs by someone tracing the route on a map gives real truth at a few hours of effort. Instrumented drives with a second logger and a written route give the highest quality and cost a day each; twenty of them is usually enough.
What mismatch fraction is acceptable?
It depends entirely on the consumer. Corridor volumes tolerate 5–10% because errors partially cancel. Turn counts and junction analysis need under 2%, because a wrong turn is exactly the quantity being measured. State the target before evaluating, or the number that comes out will become the target.
Should I evaluate confidence calibration too?
Yes, and it is cheap: bucket the matched fixes by the matcher’s reported confidence and compute the mismatch fraction in each bucket. A well-calibrated matcher shows mismatch falling monotonically with confidence, which makes the confidence usable as a filter. Many matchers do not, and knowing that is worth the hour.
Related
- Map-Matching to Road Networks — the parent reference and the sampling-rate sensitivity curve.
- Building an OSM Road Graph with OSMnx for Map Matching — the graph decisions this evaluation will expose.
- Feeding Cleaned GPS Output into an HMM Map-Matcher — the pipeline being scored.
- Hausdorff vs LCSS for Partial Trajectory Matching — geometric alternatives when link ids are unavailable.
- Speed Percentile Profiles for Corridor Benchmarking — a consumer whose quality depends directly on this score.