Measuring Conflation Precision and Recall Jump to heading
Two numbers, an afternoon of labelling, and a habit of re-scoring — that is the whole of conflation quality measurement, and it is the difference between a matcher you can defend and one you merely hope about.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Precision is correct matches divided by proposed matches. Recall is correct matches divided by true matches that exist. Computing precision is easy: sample the proposals and judge them. Computing recall is harder, because it needs the denominator — pairs that should have matched, including ones the matcher never proposed.
That asymmetry drives the sampling design. A precision sample is drawn from the matcher’s output. A recall sample must be drawn from the external records, then each one investigated to determine whether a true counterpart exists at all, which is slower per item and cannot be avoided if recall is to mean anything.
The second idea is stratification. A uniform sample of a national dataset is dominated by easy rural cases, and the resulting figure is a flattering average over slices that behave nothing alike. Sampling within strata — by density, by feature class — and reporting per stratum is what makes the numbers informative.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import math
import random
from collections import defaultdict
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.conflate.metrics")
@dataclass(frozen=True)
class Labelled:
record_id: str
stratum: str
proposed: bool # did the matcher propose a match for this record?
correct: bool | None # was the proposal right? None when none was made
truth_exists: bool # does a true counterpart exist in OSM at all?
def wilson(successes: int, total: int, z: float = 1.96) -> tuple[float, float]:
"""Wilson score interval: honest at small n, unlike the naive one.
A naive interval around 19/20 extends above 1.0, which is not a
probability; Wilson stays inside the unit interval and is the right
default for the sample sizes hand labelling can afford.
"""
if total == 0:
return (0.0, 0.0)
p = successes / total
denom = 1 + z * z / total
centre = (p + z * z / (2 * total)) / denom
spread = z * math.sqrt(p * (1 - p) / total + z * z / (4 * total * total)) / denom
return (max(0.0, centre - spread), min(1.0, centre + spread))
def stratified_sample(records: list[dict], per_stratum: int,
stratum_key: str = "stratum",
seed: int = 20260917) -> list[dict]:
"""Draw an equal number from each stratum, reproducibly."""
by_stratum: dict[str, list[dict]] = defaultdict(list)
for record in records:
by_stratum[record[stratum_key]].append(record)
rng = random.Random(seed) # fixed seed: the sample must be re-drawable
sample: list[dict] = []
for stratum, members in sorted(by_stratum.items()):
take = min(per_stratum, len(members))
if take < per_stratum:
logger.warning("stratum %s has only %d record(s)", stratum, len(members))
sample.extend(rng.sample(members, take))
logger.info("drew %d record(s) across %d stratum(s)",
len(sample), len(by_stratum))
return sample
def metrics(labels: list[Labelled]) -> dict[str, dict[str, float]]:
"""Precision and recall overall and per stratum, with intervals."""
buckets: dict[str, list[Labelled]] = defaultdict(list)
for label in labels:
buckets["ALL"].append(label)
buckets[label.stratum].append(label)
out: dict[str, dict[str, float]] = {}
for name, group in sorted(buckets.items()):
proposed = [l for l in group if l.proposed]
correct = [l for l in proposed if l.correct]
truths = [l for l in group if l.truth_exists]
found = [l for l in truths if l.proposed and l.correct]
precision = len(correct) / len(proposed) if proposed else 0.0
recall = len(found) / len(truths) if truths else 0.0
p_lo, p_hi = wilson(len(correct), len(proposed))
r_lo, r_hi = wilson(len(found), len(truths))
f1 = (2 * precision * recall / (precision + recall)
if precision + recall else 0.0)
out[name] = {
"precision": precision, "precision_lo": p_lo, "precision_hi": p_hi,
"recall": recall, "recall_lo": r_lo, "recall_hi": r_hi,
"f1": f1, "n_proposed": len(proposed), "n_truths": len(truths),
}
logger.info("%-16s precision %.2f [%.2f-%.2f] recall %.2f [%.2f-%.2f] "
"(n=%d/%d)", name, precision, p_lo, p_hi,
recall, r_lo, r_hi, len(proposed), len(truths))
return out
def regression_check(current: dict, baseline: dict, tolerance: float = 0.03) -> bool:
"""Fail when any stratum's precision fell, not just the aggregate."""
ok = True
for stratum, values in current.items():
before = baseline.get(stratum)
if before is None:
continue
drop = before["precision"] - values["precision"]
if drop > tolerance:
logger.error("%s precision fell %.3f (%.2f -> %.2f)", stratum, drop,
before["precision"], values["precision"])
ok = False
return ok
if __name__ == "__main__":
logger.info("label once, re-score on every matcher change")
Step-by-step walkthrough Jump to heading
- Fix the random seed. A sample that cannot be redrawn identically is a sample whose labels cannot be reused, which defeats the whole point of labelling.
- Sample equally per stratum, not proportionally. Proportional sampling reproduces the dataset’s imbalance and leaves the interesting strata with too few items to say anything about.
- Warn on thin strata. A stratum with fewer records than the target sample size will produce a wide interval, and knowing that in advance prevents over-reading its number.
- Record three facts per label. Whether a match was proposed, whether it was correct, and whether a true counterpart exists. Precision needs the first two; recall needs the first and third.
- Use a Wilson interval. At the sample sizes hand labelling affords, the naive interval extends outside the unit range and understates uncertainty near the extremes. Wilson does neither.
- Report the interval, not just the point. “Precision 0.94” from twenty items means something very different from the same figure from four hundred, and the interval is what makes that visible.
- Regression-check per stratum. An aggregate that holds steady while one stratum falls sharply is the normal shape of a quality regression, and only a per-stratum check catches it.
Verification Jump to heading
- The sample redraws identically. Run the sampler twice with the same seed and confirm the two samples are the same.
- Strata are balanced. Every stratum should have the target count, or a warning explaining why not.
- Intervals stay inside the unit range. A reported upper bound above one means the naive interval is being used.
- Precision and recall move in opposite directions. Tighten a threshold and confirm precision rises while recall falls; if both move together, something is wrong with the labels.
- The regression check catches a planted drop. Degrade one stratum deliberately and confirm the check fails.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Flattering precision, real complaints | Uniform sample dominated by easy cases | Stratify and sample equally per stratum |
| Labels cannot be reused | Sample drawn without a fixed seed | Seed the sampler and record the seed |
| Recall cannot be computed | Only proposals labelled | Label records regardless of outcome for the denominator |
| Confidence bound above one | Naive interval used | Use a Wilson score interval |
| Regression missed | Only the aggregate compared | Compare every stratum against its own baseline |
| Numbers drift over releases | Sample scored once and never again | Re-score on every matcher change |
| Both metrics move together | Labels inconsistent between sessions | Write down the labelling rule and apply it uniformly |
Specification reference Jump to heading
The Wilson score interval for a binomial proportion is centred on a shrunken estimate and remains within the unit interval for all sample sizes, unlike the normal-approximation interval which can extend beyond it and understates uncertainty for proportions near zero or one. It is the recommended default for the small samples typical of hand-labelled evaluation sets. See any standard treatment of binomial proportion confidence intervals for the derivation and the comparison against alternatives.
Frequently Asked Questions Jump to heading
Why sample equally per stratum rather than proportionally?
Because a proportional sample reproduces the dataset’s imbalance, and the strata you most need to measure — dense urban areas, unnamed feature classes — are usually the small ones. Sampling equally gives each stratum enough items for a usable interval, at the cost of the overall figure no longer being a population estimate. That is the right trade, because the per-stratum numbers are the useful ones anyway.
How do I measure recall without labelling everything?
By sampling from the external records rather than from the matcher’s output, and determining for each sampled record whether a true OSM counterpart exists at all. That gives the denominator recall needs. It is several times slower per item than precision labelling, because it involves searching rather than judging, which is why the recall sample is usually smaller and more heavily stratified.
Is an F1 score useful here?
As a single summary for tracking over time, yes; as a basis for decisions, rarely. It weights precision and recall equally, and conflation almost never does — an import wants precision heavily favoured, an enrichment often the reverse. Report it if it helps somebody see a trend, but make the decision on whichever of the two numbers actually matters for the destination.
How often should the sample be re-scored?
On every change to the matcher, which with stored labels costs minutes. The value of a labelled sample is entirely in reuse: labelling once and scoring once tells you where you were, while labelling once and scoring on every change tells you whether each change helped. Add the per-stratum regression check to the same run and quality drift becomes a failed check rather than a complaint months later.
Related Jump to heading
- Conflation QA & Rollback — the parent topic and where these metrics are reported.
- Scoring Conflation Candidates with Multiple Signals — the thresholds these numbers calibrate.
- Auditing a Conflation Run Before Upload — the evidence pack these metrics go into.
- Setting Quality Thresholds That Fail a Build — turning the regression check into a gate.
- Matching OSM Features to External Datasets — the matcher under measurement.
Up one level: Conflation QA & Rollback.