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.

Four channels by which an anonymised release still identifies people. four channelschannelfixthe mask was redrawn each releasepublish the stored masked filean attribute combination is uniqueband or drop the attributerow order matches the sourceshuffle before writinga derived surface is too finebandwidth โ‰ฅ the protectionA larger masking radius closes none of them spatialworkflow.io
Four channels, four different fixes; a bigger radius closes none of them.

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.

Median error between averaged masked releases and the true point positions. median error after n releases1 release214.0 m ยท 0% within 50 m2 releases134.3 m ยท 9.5%4 releases91.9 m ยท 18.1%8 releases64.3 m ยท 33.8%16 releases45.0 m ยท 57.1%0240 m4,059 Brighton & Hove address points, a fresh 50โ€“300 m donut each time spatialworkflow.io
Independent redraws are a gift to an attacker: each one is another sample of the same truth.

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.

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.

A masked release whose row order matches the source, recovering the true coordinates. row order is a join keyrowreleased xreleased ysource xsource y053041210423353029810405115308711047025306401045882531044103918531190104097no join key needed โ€” row i of the release is row i of the sourceOne line of shuffling closes this channel completely spatialworkflow.io
Illustrative coordinates; the structure is what matters โ€” position is the key.

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".

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.