Anonymised points still identify individuals
On this page
Problem statement
The dataset went through the process. Names were dropped, coordinates were masked or rounded, the map was checked, and somebody still recognised a case. Or a journalist joined the release to an address file and got a list. Or a second release of the same data, a year later, turned out to be enough on its own.
Every one of these has the same shape: the release was checked against one attack and defeated by another. The four that account for most real failures are the average-over-releases attack, the attribute attack, the row-order attack and the derived-surface attack. None of them requires special access, and none of them is stopped by a larger masking radius.
Quick answer
Check the four channels, not just the geometry:
# 1. Did the same points get masked twice with different draws?
assert previous_release.geometry.equals(current_release.geometry), "redrawn mask"
# 2. Is any record unique on its attributes within its neighbourhood?
key = list(zip(cell_id, *[gdf[c] for c in quasi_identifiers]))
assert pd.Series(key).value_counts().min() >= 5, "unique attribute combination"
# 3. Does the row order match the source?
assert not released.reset_index(drop=True).index.equals(source.index), "row order preserved"
# 4. Was any derived surface built at a bandwidth finer than the protection?
assert kde_bandwidth_m >= mask_radius_m, "KDE reveals the inputs"
The first is the most common and the most damaging. Sixteen independently drawn 50โ300 m donut masks of the same 4,059 Brighton & Hove address points, averaged, put 57.1% of them within 50 m of their true location and landed 15.8% on exactly the right address.
Redrawn masks, attribute keys, row order, derived products
1. Check whether the mask was redrawn
If the release is periodic and the code re-runs the mask each time, the protection decays as 1/โn:
| releases | median error | within 50 m | exact nearest address |
|---|---|---|---|
| 1 | 214.0 m | 0.0% | 3.6% |
| 2 | 134.3 m | 9.5% | 6.2% |
| 4 | 91.9 m | 18.1% | 8.0% |
| 8 | 64.3 m | 33.8% | 11.1% |
| 16 | 45.0 m | 57.1% | 15.8% |
The fix is to publish the same masked coordinates every time. Store the file; do not store only the code.
2. Check the attributes as a key
Geometry narrows the candidate set; attributes finish it. Count records sharing both a neighbourhood and an attribute combination, and treat any combination that is unique as a failure. Rare categories, exact ages and precise timestamps are the usual culprits.
3. Check the row order
A masked file written in the source's row order can be joined back to the source by position. The published index is enough; so is the order of features in a GeoJSON. Shuffle before writing, and drop any index column.
4. Check the derived products
A kernel density surface at a bandwidth smaller than the masking radius has a maximum near every input point. A choropleth at a geography finer than the protection does the same thing with cells. Derive everything from the masked points, and match the bandwidth to the claimed protection.
5. Check the second dataset
Two releases of the same population at different geographies can be intersected; two time slices can be differenced. If a yearly total and eleven monthly values are published, the twelfth is arithmetic.
6. Check the exports
An SVG or PDF of a "blurred" scatter carries one vector element per marker with exact coordinates. A test export of 4,059 points at 5% opacity produced a 609 KB SVG containing 4,171 <use> elements โ one per marker plus the axis ticks โ each with its own position (Example 4). The blur is in the rendering only.
7. Re-run the risk audit on the artefact
Everything above is a property of what shipped, not of what you intended. How to run a privacy check before publishing a spatial dataset is the gate that reads the file back.
Reproducing the averaging attack
The data used on this page
The true points are every OpenStreetMap node in Brighton & Hove carrying an addr:housenumber โ address points, the kind of location an attacker can download โ in British National Grid metres. The released records in Examples 2 and 3 are police.uk street-level crimes for January to March 2025 in a box over Westminster, Holborn and Bloomsbury, donut-masked once. Overpass via the maps.mail.ru mirror.
import os
import time
import geopandas as gpd
import numpy as np
import osmnx as ox
import pandas as pd
import requests
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False
ox.settings.http_user_agent = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"
area = ox.geocode_to_gdf("Brighton and Hove, England").geometry.iloc[0]
addresses = ox.features_from_polygon(area, tags={"addr:housenumber": True})
addresses = addresses[addresses.index.get_level_values(0) == "node"].to_crs(27700)
true_xy = np.c_[addresses.geometry.x, addresses.geometry.y]
def donut(xy, r_min, r_max, rng):
"""One independent 50-300 m style donut draw for every point."""
theta = rng.uniform(0, 2 * np.pi, len(xy))
r = np.sqrt(rng.uniform(0, 1, len(xy)) * (r_max**2 - r_min**2) + r_min**2)
return xy + np.c_[r * np.cos(theta), r * np.sin(theta)]
WEST, SOUTH, EAST, NORTH = -0.16, 51.505, -0.10, 51.530 # Westminster, Holborn, Bloomsbury
if not os.path.exists("london_crime_2025q1.csv"):
rows = []
for month in ("2025-01", "2025-02", "2025-03"):
for x0, x1 in zip(np.linspace(WEST, EAST, 4)[:-1], np.linspace(WEST, EAST, 4)[1:]):
for y0, y1 in zip(np.linspace(SOUTH, NORTH, 3)[:-1], np.linspace(SOUTH, NORTH, 3)[1:]):
for attempt in range(5): # the API answers bursts with 429
reply = requests.post("https://data.police.uk/api/crimes-street/all-crime", timeout=120,
data={"poly": f"{y0},{x0}:{y0},{x1}:{y1},{x1}:{y1},{x0}", "date": month})
if reply.status_code != 429:
break
time.sleep(2 ** attempt)
reply.raise_for_status() # a 503 means over 10,000 crimes: split further
rows += [{"id": c["id"], "month": c["month"], "category": c["category"],
"lon": float(c["location"]["longitude"]), "lat": float(c["location"]["latitude"]),
"street_id": c["location"]["street"]["id"]} for c in reply.json()]
pd.DataFrame(rows).drop_duplicates("id").to_csv("london_crime_2025q1.csv", index=False)
crimes = pd.read_csv("london_crime_2025q1.csv")
crimes = crimes[crimes.lon.between(WEST, EAST) & crimes.lat.between(SOUTH, NORTH)]
crimes = gpd.GeoDataFrame(crimes, geometry=gpd.points_from_xy(crimes.lon, crimes.lat), crs=4326).to_crs(27700)
print(f"{len(crimes):,} crimes, {crimes.category.nunique()} categories, {crimes.street_id.nunique():,} snap points")
moved = donut(np.c_[crimes.geometry.x, crimes.geometry.y], 50, 300, np.random.default_rng(1))
released = crimes.assign(geometry=gpd.points_from_xy(moved[:, 0], moved[:, 1], crs=27700))
print(f"{len(true_xy):,} Brighton & Hove address points; {len(released):,} released crime records")
22,293 crimes, 14 categories, 1,249 snap points
4,059 Brighton & Hove address points; 22,293 released crime records
Example 1 โ reproduce the averaging attack on your own release
import numpy as np
from scipy.spatial import cKDTree
rng = np.random.default_rng(3)
tree = cKDTree(true_xy)
acc = np.zeros_like(true_xy)
for i in range(1, 17):
acc += donut(true_xy, 50, 300, rng) # a fresh draw, as a naive pipeline would
est = acc / i
err = np.hypot(*(est - true_xy).T)
_, idx = tree.query(est, k=1)
if i in (1, 2, 4, 8, 16):
print(f"{i:2d} releases: median {np.median(err):6.1f} m | within 50 m "
f"{(err < 50).mean():5.1%} | nearest is the true address "
f"{(idx == np.arange(len(true_xy))).mean():5.1%}")
1 releases: median 214.0 m | within 50 m 0.0% | nearest is the true address 3.6%
2 releases: median 134.3 m | within 50 m 9.5% | nearest is the true address 6.2%
4 releases: median 91.9 m | within 50 m 18.1% | nearest is the true address 8.0%
8 releases: median 64.3 m | within 50 m 33.8% | nearest is the true address 11.1%
16 releases: median 45.0 m | within 50 m 57.1% | nearest is the true address 15.8%
Example 2 โ find the records that are unique on geometry plus attributes
import pandas as pd
def unique_records(gdf, quasi, cell_m=250):
cell = list(zip((gdf.geometry.x // cell_m).astype(int),
(gdf.geometry.y // cell_m).astype(int)))
key = pd.Series(list(zip(cell, *[gdf[c] for c in quasi])))
counts = key.map(key.value_counts())
return gdf.assign(group_size=counts.values).query("group_size < 5")
risky = unique_records(released, ["category", "month"])
print(f"{len(risky):,} of {len(released):,} records in a group smaller than 5")
print(risky[["category", "month", "group_size"]].head())
6,253 of 22,293 records in a group smaller than 5
category month group_size
3 anti-social-behaviour 2025-01 4
4 anti-social-behaviour 2025-01 2
6 anti-social-behaviour 2025-01 1
7 anti-social-behaviour 2025-01 2
12 anti-social-behaviour 2025-01 2
The released records here are the police.uk crimes, already moved by police.uk to anonymised map points, then donut-masked again; their attributes are the crime category and the month. A health or survey release would have age band and sex in the same place, and they divide the candidates faster still.
Example 3 โ break the row-order link before writing
released = released.sample(frac=1, random_state=20260915).reset_index(drop=True)
released = released.drop(columns=[c for c in released.columns
if c.lower() in {"index", "fid", "objectid", "source_id"}])
released.to_file("release.gpkg", layer="cases", driver="GPKG")
print(f"{len(released):,} rows written in shuffled order")
22,293 rows written in shuffled order
Example 4 โ what an exported "blurred" scatter still contains
import os
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6, 6))
ax.scatter(true_xy[:, 0], true_xy[:, 1], s=40, alpha=0.05, color="#1a3a6b")
fig.savefig("blurred.svg")
svg = open("blurred.svg").read()
print(f"{len(true_xy):,} points at 5% opacity: {os.path.getsize('blurred.svg') / 1024:,.0f} KB, "
f"{svg.count('<use')} <use> elements")
4,059 points at 5% opacity: 609 KB, 4171 <use> elements
Shuffling is free and closes a channel that otherwise recovers every point exactly. Note that the row order in the source must not be reconstructible either โ a sort by identifier is the same leak.
Why averaging and attributes defeat masks
Why averaging works
A donut mask adds independent zero-mean noise. The mean of n independent draws has a standard error 1/โn of a single draw, so the estimate converges on the truth at a rate anyone can compute. Nothing about the radius changes this; a larger radius only means more releases are needed. Publishing the same file every time removes the extra samples entirely.
Why attributes beat geometry
A neighbourhood with fifty candidate households is ample protection until the release also says the subject is a 91-year-old man. Each attribute divides the candidate set, and demographic attributes divide it fast. This is exactly the structure that re-identified medical records from a voter roll in the 1990s and it has not changed.
Why row order is a real attack
The order of features in a file is data. A masked release written with to_file on a GeoDataFrame that was never reordered has row i corresponding to source row i, and any copy of the source โ including an older, unmasked release โ recovers every point exactly. It costs one line to close and is almost never checked.
Why derived surfaces leak
Smoothing is a convolution, not a deletion. A kernel density surface built from the true points with a 25 m bandwidth has a local maximum at each of them; an SVG scatter is not smoothed at all, only drawn transparently. If the protection claimed is 300 m, no published derivative may resolve anything finer.
One redraw, sorting and free text
- A single redraw is already a loss. Two releases halve the error in the average.
- Sorting is ordering. Sorting by any source-derived key is as revealing as leaving the order alone.
- Free-text fields leak. A note saying "opposite the church" is a coordinate.
- Photograph metadata leaks. Strip it before shipping the folder.
- Counts plus points is both. Publishing an aggregate and the masked points lets one check the other.
- An old unmasked release cannot be recalled. If one exists, everything after it is joinable to it.
- Small groups fail at any radius. If a category has three members in a city, geometry is not the problem.
- Document the attacks you tested. A privacy statement that lists what was checked is worth more than one that says "anonymised".
Internal links
- Re-identification risk in spatial data explained โ the joins these attacks use
- How to apply donut geomasking to sensitive points in Python โ storing the mask rather than the seed
- Spatial k-anonymity explained โ why composition breaks the guarantee
- A shared file still contains the original coordinates โ the file-level leaks
- How to run a privacy check before publishing a spatial dataset โ testing all four channels automatically
- Differential privacy for spatial counts explained โ the defence built for repeat releases
- Kernel density explained โ why a density surface still contains the inputs
- How to measure re-identification risk in a point dataset โ the audit before release
FAQ
Why can masked points still be traced back?
Usually because the mask was redrawn for each release. Averaging sixteen independent 50โ300 m masks put 57% of points within 50 m of the truth and picked the exact right address for 15.8%.
Is a bigger masking radius the fix?
No. It slows the averaging attack and does nothing about attributes, row order or derived surfaces.
How does row order identify anyone?
If the released file keeps the source's row order, row i of the release is row i of the source, so any copy of the source restores every true coordinate exactly.
Can attributes identify someone when the geometry is safe?
Yes. Each published attribute divides the candidate set; two or three demographic fields will take fifty candidates down to one.
Is a heat map safe if the points are not?
Only if the bandwidth is at least the protection claimed. A fine-bandwidth kernel density surface has a peak at every input point.
What should I do if an unmasked version was already published?
Treat everything afterwards as joinable to it. No later masking can undo a released coordinate; the response is to change what is published, not how it is displaced.