Changeset Analysis & Vandalism Detection Jump to heading
The validation work covered elsewhere in OSM Data Quality & Validation asks whether an object is well-formed. This topic asks a different question: whether an edit looks like one a careful mapper would make. The two are independent. A changeset that deletes four thousand buildings across three countries produces perfectly valid geometry and perfectly consistent tags, and every rule engine on this site will pass it without comment.
Detecting that class of problem means looking at the diff stream rather than at the data, and treating the changeset — not the object — as the unit of analysis. It also means being clear-eyed about what the pipeline is for. Most edits that trip these signals are not vandalism; they are imports, mechanical edits, licence-driven removals and bulk retagging, all legitimate and all indistinguishable from malice by any arithmetic. The output of this pipeline is a ranked queue for a person to look at, and building it any other way produces a system that reverts good work.
Prerequisite concepts Jump to heading
This topic sits on the replication machinery. The edits arrive as OsmChange documents from the stream described in OSM Replication & Diff Sync, and the per-object metadata each operation carries — changeset identifier, user, version, timestamp — is what makes changeset-level grouping possible at all. The fields the diff does not carry, notably the changeset comment and the editor string, come from the changeset API, a split covered in Extracting Changeset Metadata from History Files.
The signals worth computing Jump to heading
Six signals do most of the work, and all six are arithmetic over a grouped diff.
Size — the count of objects touched. Cheap, and by itself almost meaningless: routine imports are enormous and a malicious edit can be three objects.
Deletion share — the proportion of operations that are deletions. This is the strongest single signal, because ordinary mapping is overwhelmingly additive and corrective. A changeset that is eighty percent deletions is doing something unusual whether or not it is malicious.
Geographic spread — the diagonal of the changeset bounding box, or better, the number of distinct H3 cells touched at a coarse resolution, using the cell scheme from Spatial Index Selection. A human editing in one session works in one place; an edit spanning continents is either a bot or a script.
Account age at edit time — the interval between account creation and the changeset. New accounts making large edits are worth attention, and this signal is also the one most likely to be unfair: new mappers exist and are welcome. Weight it low.
Editor string — created_by on the changeset. Absent or unrecognised values correlate with scripted edits. This is a weak signal and a noisy one; treat it as a tiebreaker.
Tag churn — the number of versions an object accumulates within a short window. Repeated rewriting of the same object is characteristic of an edit war rather than of vandalism, and it is worth surfacing separately because the response is different.
Computing them from a diff Jump to heading
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
import h3
import osmium
logger = logging.getLogger(__name__)
@dataclass
class ChangesetStats:
"""Everything the score needs, accumulated in one pass over the diff."""
changeset: int
user: str = ""
created: int = 0
modified: int = 0
deleted: int = 0
cells: set[str] = field(default_factory=set)
first_seen: datetime | None = None
last_seen: datetime | None = None
@property
def touched(self) -> int:
return self.created + self.modified + self.deleted
@property
def deletion_share(self) -> float:
return self.deleted / self.touched if self.touched else 0.0
class ChangesetCollector(osmium.SimpleHandler):
"""Group an OsmChange stream by changeset, accumulating scoring features."""
def __init__(self, cell_resolution: int = 3) -> None:
super().__init__()
self.resolution = cell_resolution
self.stats: dict[int, ChangesetStats] = defaultdict(
lambda: ChangesetStats(changeset=0)
)
def _record(self, obj, lat: float | None = None, lon: float | None = None) -> None:
st = self.stats[obj.changeset]
st.changeset = obj.changeset
st.user = obj.user or st.user
if not obj.visible:
st.deleted += 1
elif obj.version == 1:
st.created += 1
else:
st.modified += 1
if lat is not None and lon is not None:
st.cells.add(h3.latlng_to_cell(lat, lon, self.resolution))
ts = obj.timestamp
st.first_seen = min(st.first_seen or ts, ts)
st.last_seen = max(st.last_seen or ts, ts)
def node(self, n) -> None:
loc = n.location if n.visible and n.location.valid() else None
self._record(n, loc.lat if loc else None, loc.lon if loc else None)
def way(self, w) -> None:
self._record(w)
def relation(self, r) -> None:
self._record(r)
Two details are load-bearing. Deletions are identified by visible being false rather than by the operation block, because a <delete> block carries only the stub — the semantics set out in Applying .osc Change Files with osmium. And spread is measured in coarse H3 cells rather than as a bounding-box diagonal, because a bounding box around two edits on opposite sides of a country reports a huge area for two objects, whereas a cell count reports two.
Scoring, and why it must be explainable Jump to heading
WEIGHTS = {
"large": 2.0, # over 5 000 objects
"deletion_heavy": 3.0, # over 40% deletions
"wide": 2.0, # more than 12 coarse cells
"very_new_account": 1.5,
"unknown_editor": 0.5,
}
def score(st: ChangesetStats, account_age_days: float, editor: str | None) -> tuple[float, list[str]]:
"""Return a score and the list of reasons that produced it."""
reasons: list[str] = []
total = 0.0
if st.touched > 5_000:
reasons.append(f"large: {st.touched} objects"); total += WEIGHTS["large"]
if st.deletion_share > 0.40:
reasons.append(f"deletion-heavy: {st.deletion_share:.0%}"); total += WEIGHTS["deletion_heavy"]
if len(st.cells) > 12:
reasons.append(f"wide: {len(st.cells)} cells"); total += WEIGHTS["wide"]
if account_age_days < 1:
reasons.append("account under a day old"); total += WEIGHTS["very_new_account"]
if not editor:
reasons.append("no editor declared"); total += WEIGHTS["unknown_editor"]
return total, reasons
Returning the reasons alongside the number is not a nicety. A reviewer opening a queue item needs to know in one second why it is there, and a score with no explanation gets treated as noise. It is also the only way to tune: when the queue fills with false positives, the reason list tells you which weight to move.
Validation and error-handling matrix Jump to heading
| Condition | Root cause | Detection | Action |
|---|---|---|---|
| Queue full of legitimate imports | Size weighted too heavily | Reason lists dominated by “large” | Lower the size weight; add an import allowlist |
| Deletion share always zero | visible not checked; block type used instead |
No changeset ever flags on deletions | Read visible, not the operation block |
| Spread always 1 cell | Ways and relations have no location | Only node-only changesets score on spread | Resolve way geometry, or accept node-only spread |
| Every changeset scores 0 | Account age or editor unavailable, treated as safe | Two reasons never appear | Treat unknown as unknown, not as clean |
| Same changeset queued repeatedly | Changesets span multiple diffs | Duplicate queue entries | Key the queue on changeset id, upsert |
| Queue ignored by reviewers | Confirmed rate too low | Items ageing without action | Raise thresholds until the hit rate recovers |
The fifth row is a structural property of the stream rather than a bug: a large changeset is applied across many minutely diffs, so its statistics arrive in pieces. Accumulate into a store keyed by changeset identifier and score on a delay of an hour or so, rather than scoring each diff independently.
Performance and scale considerations Jump to heading
The grouping pass is cheap — one pass over the diff, a dictionary keyed by changeset, and a small set of H3 cells per group. On a minutely diff it is microseconds of work against an HTTP fetch. The cost lives in two other places.
The first is changeset metadata. Account age and the editor string are not in the diff, so they require an API call per changeset. At a few hundred distinct changesets a minute that is a lot of calls, and the fix is a cache: account creation dates never change, and changeset metadata is immutable once the changeset closes. A local table keyed by user identifier removes almost all of the traffic.
The second is the accumulation store if it is unbounded. Changesets stay open for up to a day, so an in-memory dictionary that never evicts grows all day. Flush and score groups whose last-seen timestamp is more than an hour old, the same bounded-accumulator pattern as in Bounded LRU Node Cache for OSM Streaming.
Failure modes and gotchas Jump to heading
The failure with real-world consequences is treating the score as a verdict. Automated reverting is a socially governed action in OpenStreetMap, it has an established process, and a pipeline that reverts on a threshold will eventually revert an import a local community spent months coordinating. Rank, explain, and hand to a person.
A quieter failure is scoring on a stream you have already filtered. If your diff-sync applies a geographic clip before scoring, every changeset looks narrow, because you deleted the evidence of spread. Score the unfiltered stream and filter afterwards.
Third, be careful with account age as a signal in isolation. It is the signal most likely to systematically flag new contributors, whose edits are usually small, local and correct. Weighted low and combined with size and deletion share it adds information; used alone it produces a queue of newcomers.
Calibrating the weights Jump to heading
Weights chosen by intuition produce a queue that is either empty or unreadable, and the only way to find out which is to run them against edits whose outcome is already known. A full-history file makes that possible: replay a year of changesets, score each one with a candidate weighting, and compare the top of the resulting queue against the changesets that were in fact reverted. Reverts are recorded in OSM as ordinary changesets whose comments follow recognisable conventions, so the ground truth is available without any manual labelling.
The metric to optimise is not accuracy, which is meaningless when the positive class is a fraction of a percent of the population. It is the confirmed rate within the number of items a reviewer will actually open in a day. If a reviewer works thirty items, the question is how many of the top thirty by score were genuinely problematic, and a weighting that scores twelve of them correctly is far more useful than one with better overall separation and three.
That framing has a consequence for thresholds. The score threshold should be set from reviewer capacity rather than from the score distribution: measure how many items a day get worked, set the cut-off so the queue holds about that many, and let the weights determine which ones they are. A queue that grows faster than it is worked is a queue that gets abandoned, and every signal in it stops mattering.
Recalibrate on a schedule rather than on incident. Tagging conventions shift, import campaigns start and finish, and a weighting tuned on last year’s edits drifts. A quarterly replay against the previous quarter’s history, comparing the queue that would have been produced against what actually needed attention, is enough to catch the drift while it is still small.
One further note on fairness. Every signal here is a proxy, and proxies encode assumptions about how mapping is done. Account age assumes established contributors are more trustworthy, which is usually true in aggregate and routinely wrong in the individual case — a new account may belong to an experienced mapper starting fresh, or to a local expert invited by a community group. Editor strings assume familiar tools mean familiar practice, which under-weights regional editors popular outside Europe and North America. Geographic spread assumes a session happens in one place, which is false for anyone doing armchair mapping from imagery.
None of that makes the signals useless; it makes them signals rather than judgements, and it is another reason the output is a queue. A reviewer looking at a flagged changeset can see immediately that a new account made a large local edit with an unfamiliar editor and recognise a mapping party rather than an attack. A threshold cannot.
In this section Jump to heading
- Scoring OSM Changesets for Suspicious Edits — the complete scorer, weights and reason lists included.
- Detecting Bulk Deletions in an OSM Diff Stream — the single highest-value check, on its own.
- Fetching OSM Changeset Metadata from the API — the comment, editor and account fields the diff does not carry, with caching.
Frequently Asked Questions Jump to heading
Should the pipeline revert anything automatically?
No. Reverting in OpenStreetMap is a community process with established conventions, and the signals here cannot distinguish a malicious mass deletion from a coordinated licence removal or a planned import cleanup. Produce a ranked, explained queue. If a reviewer decides a revert is warranted, they perform it through the normal channels.
What is the single most useful signal?
Deletion share. Ordinary mapping is overwhelmingly additive, so a changeset dominated by deletions is unusual by construction, and unlike raw size it does not flag every import. Combined with geographic spread it identifies the shape of edit that most warrants a look.
How do I avoid flagging legitimate imports?
Maintain an allowlist of accounts and editor strings known to perform coordinated imports in your area of interest, and subtract from the score rather than skipping the changeset entirely. Subtracting keeps the changeset visible if it also trips unrelated signals, which is what you want when an import account behaves unusually.
Can this run on a full-history file instead of the diff stream?
Yes, and it is the right way to backfill or to tune weights, because a history file lets you replay months of changesets and measure how a weighting would have performed. The streaming path is for detection; the history path is for calibration. The reduction is the one described in Reconstructing OSM Features at a Past Date.
Related Jump to heading
- OSM Data Quality & Validation — the section this topic belongs to.
- OSM Replication & Diff Sync — the stream these signals are computed over.
- Full-History .osh.pbf Processing — the calibration path for tuning weights against the past.
- Authoring OSM Validation Rules — object-level checks, which this deliberately is not.
- Spatial Index Selection: R-tree, H3 or Quadkey — the cell scheme the spread signal uses.
- Applying .osc Change Files with osmium — the operation semantics the deletion signal depends on.
Up one level: OSM Data Quality & Validation.