Auditing a Conflation Run Before Upload Jump to heading
The audit is written for somebody who did not build the pipeline, does not want to run it, and has the authority to say no. Everything about its format follows from that.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
An evidence pack answers four questions in the order a reviewer asks them.
What did it do? Counts, sliced. Not “50,000 records processed” but how many matched, how many were routed to review, how many found nothing, broken down by the slices that behave differently.
Can I see some? Examples. Not the twenty best ones — a sample spread across the score range, deliberately including pairs just either side of the threshold, because those show what the decision actually looks like at its boundary.
What are the rules? The thresholds, the precedence and the cardinality rule, stated in sentences. A reviewer should not have to read code to know what “confident” meant.
Where is it weak? The limitations, admitted. A pack that names its own weak spots is far more credible than one that does not, and the reviewer will find them regardless — better from you than from them.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import random
from dataclasses import dataclass
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.conflate.audit")
SLICES = ["density_band", "feature_class", "region"]
EXAMPLES_PER_BAND = 4
SEED = 20260917
@dataclass(frozen=True)
class Rules:
match_floor: float
review_floor: float
min_gap: float
cardinality: str
precedence: str
def as_sentences(self) -> list[str]:
return [
f"A pair is a confident match when its combined score is at least "
f"{self.match_floor:.2f} AND it beats the next candidate by at "
f"least {self.min_gap:.2f}.",
f"A pair scoring between {self.review_floor:.2f} and "
f"{self.match_floor:.2f}, or beating its runner-up by less than "
f"{self.min_gap:.2f}, goes to human review.",
f"Below {self.review_floor:.2f} the record is recorded as having no "
f"match, which is an expected outcome rather than a failure.",
f"Cardinality: {self.cardinality}.",
f"Precedence when both sources have a value: {self.precedence}.",
]
def sliced_counts(pairs: pd.DataFrame) -> pd.DataFrame:
"""Counts by outcome, for the whole run and for every slice."""
rows = []
overall = pairs["outcome"].value_counts()
rows.append({"slice": "ALL", "value": "ALL", **overall.to_dict()})
for column in SLICES:
if column not in pairs.columns:
logger.warning("slice column %r absent; the pack will be weaker", column)
continue
for value, group in pairs.groupby(column):
rows.append({"slice": column, "value": str(value),
**group["outcome"].value_counts().to_dict()})
frame = pd.DataFrame(rows).fillna(0)
numeric = [c for c in frame.columns if c not in {"slice", "value"}]
frame[numeric] = frame[numeric].astype(int)
frame["match_rate"] = frame.get("match", 0) / frame[numeric].sum(axis=1)
return frame
def threshold_examples(pairs: pd.DataFrame, rules: Rules) -> pd.DataFrame:
"""Examples across the score range, weighted towards the boundary.
Showing only high-scoring pairs proves nothing: a reviewer needs to see
what a marginal decision looks like, because that is where errors live.
"""
rng = random.Random(SEED)
bands = {
"well above": pairs[pairs["score"] >= rules.match_floor + 0.10],
"just above": pairs[(pairs["score"] >= rules.match_floor)
& (pairs["score"] < rules.match_floor + 0.03)],
"just below": pairs[(pairs["score"] < rules.match_floor)
& (pairs["score"] >= rules.match_floor - 0.03)],
"small gap": pairs[(pairs["score"] >= rules.match_floor)
& (pairs["gap"] < rules.min_gap)],
"no match": pairs[pairs["score"] < rules.review_floor],
}
chosen = []
for band, group in bands.items():
if group.empty:
logger.warning("no examples in band %r", band)
continue
take = min(EXAMPLES_PER_BAND, len(group))
picks = rng.sample(list(group.index), take)
sample = group.loc[picks].copy()
sample.insert(0, "band", band)
chosen.append(sample)
return pd.concat(chosen) if chosen else pd.DataFrame()
def limitations(pairs: pd.DataFrame, counts: pd.DataFrame) -> list[str]:
"""Derive the weak spots from the data rather than relying on memory."""
notes: list[str] = []
worst = counts[counts["slice"] != "ALL"].nsmallest(1, "match_rate")
if not worst.empty:
row = worst.iloc[0]
notes.append(f"Lowest match rate is {row['match_rate']:.0%} in "
f"{row['slice']}={row['value']}; treat results there as weaker.")
thin = pairs[pairs["evidence_weight"] < 0.6]
if len(thin):
notes.append(f"{len(thin)} pair(s) ({len(thin)/len(pairs):.1%}) were decided "
f"on less than 60% of the available evidence.")
reviewed = (pairs["outcome"] == "review").sum()
notes.append(f"{reviewed} pair(s) require human review before use; no "
f"automated decision was made for them.")
return notes
def build_pack(pairs: pd.DataFrame, rules: Rules, path: str) -> None:
counts = sliced_counts(pairs)
examples = threshold_examples(pairs, rules)
notes = limitations(pairs, counts)
with open(path, "w", encoding="utf-8") as fh:
fh.write("# Conflation run audit\n\n## Counts\n\n")
fh.write(counts.to_markdown(index=False))
fh.write("\n\n## Decision rules\n\n")
for sentence in rules.as_sentences():
fh.write(f"- {sentence}\n")
fh.write("\n## Examples\n\n")
fh.write(examples.to_markdown(index=False))
fh.write("\n\n## Known limitations\n\n")
for note in notes:
fh.write(f"- {note}\n")
logger.info("wrote evidence pack to %s", path)
if __name__ == "__main__":
logger.info("publish the pack where every changeset comment can link to it")
Step-by-step walkthrough Jump to heading
- Slice the counts, always. One total tells a reviewer nothing about where the run is weak. The slices are the same ones the metrics use, so the two documents agree.
- Warn when a slice is missing. A pack without the density breakdown is weaker, and saying so is better than quietly producing a thinner document.
- Sample examples at the boundary. Four bands around the threshold plus one well above and one well below. A reviewer learns far more from a pair that only just qualified than from an obvious one.
- Fix the sampling seed. The examples in the pack must be the same ones next time, or a reviewer returning to a question finds different data.
- State the rules as sentences. Generating the prose from the same constants the pipeline uses means the document cannot drift away from the code.
- Derive limitations from the data. The worst-performing slice, the share of thin-evidence decisions and the review backlog are computed rather than remembered, so they stay honest as the run changes.
- Write markdown, not a dashboard. A file that can be read in a browser, linked from a changeset comment and archived is what a reviewer will actually use.
Verification Jump to heading
- The pack reproduces. Rebuild it from the same run and confirm the examples and counts are identical.
- Every band has examples. An empty band usually means a threshold is placed where no data falls, which is itself worth knowing.
- The rules match the code. The stated thresholds must be the ones the pipeline used — generating them from the same constants makes this automatic.
- The limitations are unflattering. If the derived notes read well, check the derivation rather than celebrating.
- A reviewer can answer questions from it alone. Give it to a colleague and see whether they need to ask you anything that the pack should have contained.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Reviewer asks for a breakdown | Only aggregate counts included | Slice by density, class and region |
| Examples all obviously correct | Sampled from high scores only | Sample bands around the threshold |
| Examples differ between builds | Unseeded sampling | Fix the seed and record it |
| Stated rules differ from behaviour | Prose written by hand | Generate the sentences from the pipeline’s constants |
| Limitations section empty | Weak spots recalled rather than computed | Derive them from the run’s own data |
| Pack unusable during review | Delivered as a dashboard needing access | Write a file that can be linked and archived |
| Nobody reads it | Not linked from the changesets | Reference the published pack in every changeset comment |
Specification reference Jump to heading
The OpenStreetMap community’s expectations for automated and bulk edits include documenting the edit, discussing it in advance on the relevant channels, and making the documentation discoverable from the changesets themselves. An evidence pack satisfies the documentation requirement in a form a reviewer can act on. See the automated edits code of conduct and the import guidelines for what reviewers expect to find.
Frequently Asked Questions Jump to heading
Why include examples that were rejected?
Because a reviewer’s real question is where the boundary sits, and only marginal cases answer it. A pack showing twenty obviously correct matches demonstrates that the easy cases work, which nobody doubted. Showing what was accepted at the weakest point and what was rejected just below it lets a reviewer judge whether the threshold is in the right place — which is the one judgement they are actually qualified to make without running anything.
Should the pack admit its weaknesses?
Yes, and derive them from the data rather than from memory. A reviewer will find the weak spots — they are usually the first thing an experienced one looks for — and finding an unmentioned one discounts everything else in the document. A pack that names its worst-performing slice, its thin-evidence decisions and its review backlog reads as honest, which is the property that determines whether the rest is believed.
How do I keep the stated rules in step with the code?
Generate the prose from the same constants the pipeline uses, rather than writing it by hand. A threshold described in a document and a threshold used in a run will eventually differ if they are maintained separately, and the discrepancy is discovered at the worst possible moment. Rendering sentences from the configuration object costs a few lines and removes the drift entirely.
Is a dashboard not better than a file?
Not for this purpose. The pack has to be linkable from a changeset comment, readable by somebody without access to your systems, and archivable so the same document can be re-read in two years when a question arises. A dashboard satisfies none of those. Build the dashboard too if it helps your team, but the artefact that accompanies the upload should be a file.
Related Jump to heading
- Conflation QA & Rollback — the parent topic and the four artefacts a run should produce.
- Measuring Conflation Precision and Recall — the metrics this pack reports.
- Preparing an OSM Import — the plan this pack accompanies.
- Dry-Running a Bulk Edit Against the Dev API — the rehearsal that produces some of this evidence.
- Generating an OSM Data Quality Report — the same reporting discipline for pipeline quality.
Up one level: Conflation QA & Rollback.