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.

The four sections of an evidence pack and the question each answers Four stacked sections. The counts section answers what the run did, sliced by the dimensions that behave differently rather than reported as one total. The examples section answers whether a reviewer can see the decisions, sampled across the score range including pairs at the threshold. The rules section answers what the thresholds and precedence actually were, stated in sentences rather than as code. The limitations section answers where the run is weak, admitted openly because a reviewer will find the weak spots anyway. Four sections, in the order a reviewer asks Counts What did the run do? sliced, never one total Examples Can I see some decisions? across the score range Rules What did confident mean? sentences, not code Limitations Where is this weak? admitted, not discovered A pack that omits the last section reads as marketing, and a reviewer who finds an unmentioned weakness discounts the other three.
The order matters: a reviewer who cannot answer the first question never reaches the second.

Runnable solution Jump to heading

python
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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. State the rules as sentences. Generating the prose from the same constants the pipeline uses means the document cannot drift away from the code.
  6. 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.
  7. 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.
What each example band shows a reviewer A grid of five example bands against what each demonstrates and why it belongs in the pack. Pairs well above the threshold show what an easy correct match looks like and set the reader's baseline. Pairs just above show what the weakest accepted decision looks like, which is where precision errors concentrate. Pairs just below show what was rejected at the margin, revealing whether the threshold is costing real matches. Pairs with a small runner-up gap show the ambiguity the gap rule exists to catch. Pairs well below show that no-match is a real and reasonable outcome. Five bands, five different things they prove Shows Why it belongs Well above an easy correct match sets the baseline Just above the weakest acceptance where errors concentrate Just below marginal rejection is the threshold costly? Small gap genuine ambiguity why the gap rule exists Well below a reasonable no-match no-match is not failure A pack showing only the first band is the one reviewers learn to distrust, because it demonstrates nothing about the decision boundary.
The two middle bands are where a reviewer forms their actual opinion of the matcher.
Which questions reviewers actually ask, by how often they come up Five questions ranked by how frequently reviewers raise them on a bulk edit proposal. Asking how the existing data was checked for duplicates is the most common. Asking what the tagging maps to and who agreed it comes next. Asking how a mistake would be undone is close behind. Asking what the licence position is follows. Asking about the matching algorithm itself is the least common, which is usually the opposite of what the pipeline's authors expect. What reviewers ask, in order of frequency How was duplication checked? most common What does the tagging map to? very common How would this be undone? common What is the licence position? regular How does the matcher work? rarely asked The algorithm is the part authors most want to explain and the part reviewers care least about, which is worth knowing before writing.
Structure the pack around the top three questions and most review threads finish in one exchange.

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.

Up one level: Conflation QA & Rollback.