Re-identification risk in spatial data explained
Problem statement
Nobody re-identifies a dot by looking at it. They join it to something. That is the only mechanism, and it is the reason "we removed the names" and "the dots are too small to read" are both irrelevant: the attack happens in a database, not on a map.
The joins are cheap and public. Address files, property listings, electoral rolls, company registers, obituaries, planning applications and OpenStreetMap all contain coordinates or addresses for identifiable people and places. A released point becomes a name as soon as it falls within a few metres of exactly one record in any of them.
What makes this hard to reason about is that risk is not a property of the dataset. It is a property of the dataset plus the auxiliary data plus the number of times you publish. This guide sets out the three in the order they bite, and shows how to put a number on each.
Quick answer
Measure risk as "how many candidates does each released point leave?", using a public address file as the attacker's table:
import numpy as np
from scipy.spatial import cKDTree
tree = cKDTree(np.c_[addresses.geometry.x, addresses.geometry.y]) # attacker's auxiliary data
dist, idx = tree.query(np.c_[released.geometry.x, released.geometry.y], k=1)
singled_out = dist < 10 # within 10 m of one known address
print(f"{singled_out.sum():,} of {len(released):,} points ({singled_out.mean():.1%}) "
f"sit on a single address")
A point that is closer to one address than to any other is a name. On the unmasked Brighton & Hove OpenStreetMap address extract โ 3,109 points โ every single one was its own nearest address, which is the worst case by construction and the case an unmasked release of home locations actually is.
Step-by-step solution
1. Write the attacker's table down explicitly
Risk cannot be computed without naming what the point is joined to. In order of how easily each is obtained:
| Auxiliary source | Typical precision | Effort |
|---|---|---|
| OpenStreetMap addresses | rooftop, partial coverage | minutes |
| National address file (AddressBase, NAD, BAG) | rooftop, complete | a licence or a download |
| Property sale listings | rooftop, recent transactions | scraping |
| Electoral roll / phone book | address + name | varies by jurisdiction |
| Company register | registered office | free |
Complete coverage matters more than precision. A file that has 60% of addresses halves an attacker's certainty; a complete one does not.
2. Count the candidates each point leaves
The quantity to report is the size of the candidate set: how many plausible subjects a released point is consistent with. One is a re-identification. Five is the usual regulatory floor. The number is computed the same way whatever the release method โ count the auxiliary records within the release's uncertainty radius.
3. Distinguish population density from address density
Two released points 200 m apart in a city centre and in a village have wildly different risk, and the variable that explains it is how many possible subjects share the uncertainty area. A fixed masking radius therefore produces uneven protection: the same 300 m circle covers hundreds of dwellings in a terrace and two farms on a moor. Spatial k-anonymity explained is the fix.
4. Check the attributes, not just the geometry
Location narrows the field; attributes finish the job. A released point with age_band, sex and date attached can be unique among the households its geometry is consistent with even when the geometry alone leaves fifty candidates. Treat every published column as part of the key.
5. Check the repeat-release channel
The single most common failure in practice is not a bad first release. It is a second one. Masked points redrawn independently average towards the truth, an updated boundary file lets two aggregations be subtracted, and a monthly series of counts turns one suppressed cell into an arithmetic problem. Decide the release cadence before the method.
6. Check the derived products
A kernel density surface, a hotspot map, an interpolated raster and an SVG export all carry the input points forward in a form that can be inverted. A KDE at a 25 m bandwidth has a local maximum near every input point; an SVG scatter has one <use> element per point with exact coordinates in its attributes.
Code examples
Example 1 โ candidate set size at a given uncertainty radius
import numpy as np
from scipy.spatial import cKDTree
tree = cKDTree(np.c_[addresses.geometry.x, addresses.geometry.y])
released_xy = np.c_[released.geometry.x, released.geometry.y]
for radius in (25, 50, 100, 250, 500):
k = np.array([len(tree.query_ball_point(p, radius)) for p in released_xy])
print(f"{radius:4d} m: median candidates {np.median(k):5.0f}, "
f"points with fewer than 5 candidates {(k < 5).mean():6.1%}")
Run it on the release, not on the source. The radius is whatever the masking or rounding guarantees; for an unmasked release it is the coordinate precision.
Example 2 โ the same released point in two places
import geopandas as gpd
dense = gpd.read_file("city_centre_addresses.gpkg")
sparse = gpd.read_file("rural_addresses.gpkg")
for name, gdf in [("city", dense), ("rural", sparse)]:
area_km2 = gdf.total_bounds[[2, 3]].prod() / 1e6 # rough, for illustration
print(f"{name}: {len(gdf)/area_km2:.0f} addresses per kmยฒ; "
f"a 300 m mask covers about {len(gdf)/area_km2 * np.pi * 0.3**2:.0f} of them")
A 300 m radius is 0.283 kmยฒ. At 4,000 addresses per kmยฒ it hides a point among about 1,100 candidates; at 20 per kmยฒ it hides it among six.
Example 3 โ the repeat-release attack, measured
import numpy as np
from scipy.spatial import cKDTree
rng = np.random.default_rng(3)
def donut(xy, rmin, rmax):
theta = rng.uniform(0, 2 * np.pi, len(xy))
r = np.sqrt(rng.uniform(0, 1, len(xy)) * (rmax**2 - rmin**2) + rmin**2)
return xy + np.c_[r * np.cos(theta), r * np.sin(theta)]
tree = cKDTree(true_xy)
acc = np.zeros_like(true_xy)
for i in range(1, 17):
acc += donut(true_xy, 50, 300) # a fresh mask each month
est = acc / i
err = np.hypot(*(est - true_xy).T)
if i in (1, 2, 4, 8, 16):
print(f"{i:2d} releases: median error {np.median(err):6.1f} m, "
f"within 50 m {(err < 50).mean():5.1%}")
1 releases: median error 215.5 m, within 50 m 0.0%
2 releases: median error 139.2 m, within 50 m 8.6%
4 releases: median error 92.4 m, within 50 m 17.2%
8 releases: median error 62.7 m, within 50 m 36.4%
16 releases: median error 44.5 m, within 50 m 59.0%
Sixteen monthly releases of the same 3,109 Brighton points, each masked to 50โ300 m, put 59.0% of them within 50 m of the truth. Mask once and store the mask, or the protection decays as 1/โn.
Explanation
Why candidate-set size is the right number
Probability of re-identification is hard to defend, because it depends on assumptions about the attacker. Candidate-set size is a count you can compute from public data and state without modelling anyone's intentions: "each released point is consistent with at least k addresses". It is also what regulators ask for, under the name k-anonymity.
Why partial auxiliary data understates risk
Using OpenStreetMap as the attacker's file makes risk look lower than it is, because OSM address coverage is incomplete: 3,109 nodes for a city of about 290,000 people is a small fraction of its dwellings. A complete national address file will find neighbours the OSM-based test missed and will therefore raise the measured candidate count, but it will also find a match for every released point. Use the most complete file you can get, and say which one you used.
Why attributes multiply rather than add
Location leaves k candidates; an attribute that splits the population into m roughly equal groups leaves about k/m. Two or three such attributes will finish off a candidate set of fifty. This is why a "low-risk" geometry combined with date of birth and sex is not low risk, and it is the same arithmetic that made a US voter roll re-identify medical records in the 1990s.
Why derived surfaces are not safer than points
Smoothing is not destruction. A kernel density estimate is a sum of kernels centred on the inputs; with a known bandwidth, the inputs can be recovered approximately by deconvolution, and with a small bandwidth they can be read off the local maxima. Publish the surface at a bandwidth that is at least as large as the protection you claim, and clamp the cell size to match.
Edge cases or notes
- Uniqueness is not symmetric. A point that matches one address is a re-identification; an address that matches one point is not necessarily.
- Outliers leak first. The most isolated 1% of points carry most of the risk and are usually the ones a fixed radius fails to protect.
- Small subgroups fail everywhere. A rare category can be unique within a whole city.
- Time is a quasi-identifier. An exact timestamp plus a neighbourhood is often unique.
- The "attacker" is often a colleague. Most real re-identifications are casual recognition by someone who knows the area.
- Re-identification does not require certainty. A 1-in-3 guess is harmful if the attribute is sensitive enough.
- Geocoded addresses inherit the geocoder's bias. Interpolated street-segment matches leave larger candidate sets than rooftop matches, so risk varies by how the data was geocoded.
- Publishing the method is still correct. Security by obscurity fails here as everywhere; publish the parameters and rely on them being sufficient.
Internal links
- Geoprivacy explained: why coordinates are personal data โ why the geometry column is the identifier
- Spatial k-anonymity explained โ turning candidate-set size into a rule
- How to measure re-identification risk in a point dataset โ the full audit script
- Geomasking methods explained: donut, random and adaptive โ what a masking radius does and does not buy
- Anonymised points still identify individuals โ the failures this measurement predicts
- Differential privacy for spatial counts explained โ the defence designed for repeat queries
- How to geocode addresses in Python โ where the auxiliary data comes from
- Kernel density estimation explained โ why a density surface still contains the points
FAQ
What is re-identification, exactly?
Linking a record in a de-identified release back to the individual it describes, usually by joining it to another dataset that contains both the same quasi-identifiers and a name.
How do I measure risk without the attacker's data?
Use the best public substitute โ a national or OpenStreetMap address file โ and report which one you used. The measurement is a lower bound on risk, not an upper one.
Does masking to 300 m make a point safe?
It depends entirely on how many possible subjects live within 300 m. In a dense terrace that is over a thousand dwellings; on a moor it can be two.
Why is publishing monthly worse than publishing once?
Independent masks average out. After sixteen monthly releases of the same points, 59% were within 50 m of their true location in the test above.
Do attributes matter if the geometry is protected?
Yes. Each additional published attribute divides the candidate set, and two or three ordinary demographic fields will reduce fifty candidates to one.
Is a heat map safe to publish?
Only if the bandwidth is at least as large as the spatial protection you are claiming. At a small bandwidth the surface has a maximum at every input point.