Interpreting OSRM Match Confidence Scores

OSRM’s /match service returns a confidence between zero and one on every matching. It looks like exactly what a fleet pipeline wants: a per-trace quality number that can be thresholded, aggregated and reported. It is used that way constantly, and the interpretation is wrong in a way that matters.

Confidence measures how well the observations fit the path the matcher selected. It says nothing about whether that path is the one the vehicle drove. Those coincide most of the time and diverge precisely where matching is difficult — parallel carriageways, service roads, dense junctions — which means the metric is least informative exactly where a quality signal would be most useful.

This page sets out what the number does measure, how to calibrate it so a threshold means something, and the triage policy it genuinely supports. It extends OSRM integration for fleet map matching.


Compatibility and Configuration Requirements

Requirement Value Notes
OSRM 5.27+ confidence present on each matching object
Request overview=false, annotations=true Keeps the payload small while retaining per-point data
Output handling Per matching, not per trace A split trace returns several matchings
Calibration set A few hundred labelled traces Needed once, to learn what a value means
Storage Store the raw value, not a boolean Thresholds change; the raw number should survive

What the Number Is

OSRM derives confidence from the likelihood of the selected path relative to the alternatives it considered, normalised into a zero-to-one range. Three consequences follow directly and explain most of the surprises.

It falls when candidates are ambiguous, not when the answer is wrong. A long motorway run with no parallel roads scores near one because there was nothing else to choose. A city-centre trace scores lower because there were many plausible paths — even when the chosen one is correct.

It is per matching. A trace that OSRM splits into three matchings returns three values. Averaging them produces a number that hides the split, which is usually the more important signal.

It is not comparable across engines or across major versions. The normalisation is an implementation detail, and it has changed between OSRM releases.

Confidence against actual correctness Observed correct-edge rate plotted against reported confidence for eight hundred labelled traces. The relationship is weak and positive below 0.6 and almost flat above it, with a visible cluster of traces scoring above 0.9 whose matches are under 60 percent correct — parallel-road failures. 800 labelled traces, correctness against reported confidence 100 % 60 % 20 % confident and wrong 0.0 1.0 reported confidence Low confidence reliably indicates difficulty. High confidence does not indicate correctness. The dashed cluster is parallel-road snapping: a clean fit to entirely the wrong carriageway.

Calibrating It Once

The relationship above is specific to a fleet’s network and sampling rate, so it is worth measuring rather than assuming. Take the labelled set from building a ground-truth set, bin by reported confidence, and record observed correctness per bin.

import polars as pl

def calibrate(scored: pl.DataFrame, bins: int = 10) -> pl.DataFrame:
    """Observed correctness per confidence decile — run once, store the table."""
    return (
        scored.with_columns((pl.col("confidence") * bins).floor().alias("bin"))
        .group_by("bin")
        .agg(
            pl.col("correct_edge_rate").mean().alias("observed"),
            pl.len().alias("n"),
        )
        .sort("bin")
    )

The output is a small lookup table, and it is what makes a threshold defensible. “We review anything below 0.55” is arbitrary; “we review anything below 0.55, where observed correctness falls under 80 percent” is a policy with a reason.

Re-derive it after any change to sampling rate, receiver hardware or operating area. All three move the curve, and a threshold calibrated on last year’s fleet quietly stops meaning what it did.


A Triage Policy That Works

The defensible use of confidence is routing attention, not accepting results.

Confidence Action Reasoning
Below 0.4 Reject, or re-match with wider parameters Observed correctness under 60 % in most fleets
0.4 – 0.7 Accept, flag for sampling review Ambiguous; useful for aggregate work, not for disputes
Above 0.7 Accept No further information available from this signal

Note what the table does not do: it does not treat a high value as evidence. Everything above the middle band is accepted because there is nothing better to go on, not because confidence has certified it.

The other half of the policy is the matching count. A trace returned as four separate matchings has been broken three times, and that is a stronger negative signal than any of the four confidence values. Check it first.

def triage(matchings: list[dict]) -> str:
    if len(matchings) > 1:
        return "review"                       # the trace was split — that is the finding
    c = matchings[0]["confidence"]
    return "reject" if c < 0.4 else ("sample" if c < 0.7 else "accept")
Four confident matchings are worse than one uncertain one The same journey returned two ways. As a single matching with confidence 0.62 it is complete and slightly uncertain. Split into four matchings each with confidence above 0.9, it is three gaps wide — and averaging the four confidences reports 0.93, which looks better than the complete match. Same journey, two outcomes one matching confidence 0.62 — complete, mildly ambiguous four matchings confidences 0.94, 0.91, 0.96, 0.92 — mean 0.93 and three stretches of the journey are missing entirely Any pipeline that averages confidence per trace ranks the lower row above the upper one. Check the matching count before the confidence; a split trace is a defect regardless of how each part scored. Splits usually mean a gap exceeded the connectivity the graph could bridge — see the gap-filling policy upstream.

Using It as a Change Detector

Where confidence genuinely earns its place is in monitoring. Its absolute level is uninformative; a change in the shape of its distribution is a reliable signal that something upstream moved — a map refresh, a firmware rollout, a new operating area.

Track the full histogram daily rather than the mean, and alert on divergence from a trailing baseline. The mean is insensitive to exactly the change that matters: a bimodal distribution developing a second peak at low confidence can leave the mean almost unchanged.

This is the same signal used in detecting map-matching drift, and it works there for the same reason: it needs no labels and it responds within a day.


Common Pitfalls Specific to This Technique

Averaging confidence across matchings. It produces a number that looks better the more broken the trace is, as the figure above shows.

Reporting mean confidence as a quality metric to stakeholders. It will be read as accuracy, it will be trended, and the first time somebody labels a sample the two will disagree publicly.

Thresholding on a value taken from documentation. The distribution is fleet-specific. A threshold of 0.5 might reject two percent of traces on one fleet and thirty on another.

Storing the triage decision instead of the raw value. Thresholds change. A stored boolean cannot be re-evaluated; a stored float can.


What to Store Alongside It

Confidence is most useful in combination with three other values the same response already contains, and storing all four costs almost nothing.

The matching count. How many separate matchings the trace produced. As established above, this outranks confidence as a quality signal and is a single integer.

The unmatched tracepoint count. OSRM reports null for tracepoints it could not place. A trace with a high confidence and eleven null tracepoints is not a good match; it is a good match of the sixty percent of the trace that was placed.

The matched distance against the input’s straight-line distance. A ratio far above one indicates the matcher routed a long way round to explain the observations, which is the signature of a parallel-road or wrong-carriageway failure.

def quality_row(response: dict, trace) -> dict:
    m = response["matchings"]
    tps = response["tracepoints"]
    return {
        "confidence": m[0]["confidence"] if len(m) == 1 else None,
        "n_matchings": len(m),
        "n_unmatched_points": sum(1 for t in tps if t is None),
        "distance_ratio": sum(x["distance"] for x in m) / max(trace.straight_line_m, 1.0),
    }

Together these four make a usable triage score, and none of them requires a label. The confidence on its own is the weakest of the four, which is worth knowing given how often it is the only one stored.

Reporting to people outside the team

The temptation is to publish mean confidence as a quality metric because it is available daily and needs no labelling. Resist it. It will be read as accuracy, trended in a dashboard, and eventually contradicted publicly when somebody labels a sample.

Publish the unmatched rate and the split rate instead. Both are unambiguous, both are things a non-specialist can reason about, and neither invites a claim the data cannot support.

Four signals, ranked by usefulness Measured against labelled traces, the matching count is the strongest predictor of an incorrect match, followed by the count of unmatched tracepoints and the ratio of matched distance to straight-line distance. Reported confidence is the weakest of the four, and it is usually the only one stored. How well each signal predicts an incorrect match matching count > 1 strong null tracepoints strong distance ratio moderate confidence weak — and usually the only one kept All four come from the same response at no extra cost, and three of them beat the one everybody stores. Store all four as raw values; thresholds change, and a stored boolean cannot be re-evaluated.