How to measure re-identification risk in a point dataset

On this page

Problem statement

Before you can decide how to protect a point dataset you have to know how exposed it is, and "it looks fine on the map" is not a measurement. The audit has to produce numbers somebody can put in a data-sharing agreement: how many records sit alone on a location, how many candidate subjects each released point leaves, what the smallest publishable cell contains, and which handful of records carry most of the risk.

The hard part is not the code. It is remembering that the dataset you are auditing is not the file โ€” it is the file plus every filter the release exposes, plus whatever an attacker can join to it. This guide runs that audit end to end on real data: 4,059 OpenStreetMap address points in Brighton & Hove, and 4,013 UK police crime records for central Leicester that have already been anonymised by the publisher, audited against Ordnance Survey's complete list of 39,262 addressable locations in the same box.

Quick answer

Three numbers, in this order:

import numpy as np, pandas as pd, geopandas as gpd
from scipy.spatial import cKDTree

pts = gpd.read_file("release.gpkg").to_crs(27700)          # a metric CRS
xy = np.c_[pts.geometry.x, pts.geometry.y]

# 1. how unique is each location?
key = pd.Series(list(map(tuple, xy.round(1))))
print("rows alone on their coordinate:", int((key.value_counts() == 1).sum()))

# 2. how many candidate subjects does each point leave?
tree = cKDTree(np.c_[addresses.geometry.x, addresses.geometry.y])
k = np.array([len(tree.query_ball_point(p, 50)) for p in xy])
print(f"k at 50 m: min {k.min()}, median {np.median(k):.0f}, below 5: {(k < 5).sum()}")

# 3. what is the smallest cell the release exposes?
cells = pts.groupby([key, pts["month"], pts["category"]]).size()
print("smallest published cell:", cells.min())
rows alone on their coordinate: 51
k at 50 m: min 1, median 68, below 5: 222
smallest published cell: 1

On the published Leicester crime extract, already snapped to 368 shared points by the police, only 51 records are alone on their coordinate and the median point has 68 addresses within 50 m โ€” but 222 points have fewer than five, and the smallest published cell is 1, in 1,501 of 2,211 cells. On the unmasked Brighton addresses the first number is 4,059 of 4,059: every record alone.

The four passes of a re-identification audit, in order. the audit1uniqueness at published precisionrounded, not raw2candidates within the uncertaintynamed address file3smallest cell across every filterthe cross-product4rank and read the worst recordsa list a person decides onThe last pass is the one that produces a decision spatialworkflow.io
Four passes; the last one names the records that need a decision.

Step-by-step solution

1. Reproject to metres before anything else

Every distance in the audit is in metres. Reproject once at the top, and record the CRS in the report โ€” a risk number without a CRS is not reproducible.

2. Measure coordinate uniqueness

Count how many records share each rounded coordinate. Round at the precision you intend to publish, not at full precision, or you measure the source rather than the release.

for dp in (6, 5, 4, 3, 2):
    key = pd.Series(list(zip(lat.round(dp), lon.round(dp))))
    vc = key.value_counts()
    print(f"{dp} dp: {len(vc):5,} distinct, {int((vc == 1).sum()):5,} occupied by one record")
6 dp: 4,059 distinct, 4,059 occupied by one record
5 dp: 4,058 distinct, 4,057 occupied by one record
4 dp: 3,280 distinct, 2,598 occupied by one record
3 dp:   749 distinct,   242 occupied by one record
2 dp:    71 distinct,     3 occupied by one record

Three decimal places โ€” a cell of about 111 m by 70 m at this latitude โ€” still leaves 242 of the 4,059 points alone in their cell.

3. Count candidate subjects against a real auxiliary file

This is the number that means something. Use the most complete address or premises file you can obtain, and record which one. Spatial k-anonymity explained covers what the count means.

4. Enumerate the smallest cell the release exposes

Cross every dimension a user can filter by. The Leicester extract is a good example of the gap between the two:

unit cells cells of exactly one
snap point 368 51 (13.9%)
point ร— month 866 259 (29.9%)
point ร— category 1,517 840 (55.4%)
point ร— month ร— category 2,211 1,501 (67.9%)

5. Rank the records by risk and look at the top

Risk is concentrated. The isolated points, the rare categories and the records whose attributes are unique in their neighbourhood are a small, enumerable set, and they are where the release decision actually lives.

6. Test the attributes as well as the geometry

For each record, count how many other records share both its neighbourhood and its attribute combination. A record that is unique on both is a re-identification waiting for an auxiliary file.

7. Write the report as a file, not a message

The audit belongs next to the data, with the date, the auxiliary source, the CRS, the parameters and the numbers. How to run a privacy check before publishing a spatial dataset turns it into a pipeline gate.

Cells holding exactly one record at snap-point level and once crossed with filters. singleton cells, Leicester 2025 Q1snap point only51 of 368 pointsร— month259 of 866ร— category840 of 1,517ร— month ร— category1,501 of 2,2110100%4,013 crimes, already published as an anonymised extract spatialworkflow.io
The same dataset, two units: 13.9% singletons becomes 67.9% once the filters are applied.

Code examples

The data used on this page

Three real extracts. Every OpenStreetMap node in Brighton & Hove carrying an addr:housenumber (the uniqueness profile in step 2, as lat/lon); street-level crime from data.police.uk for January to March 2025 in a box over central Leicester (1.150ยฐW to 1.115ยฐW, 52.625ยฐN to 52.645ยฐN), saved as leicester_2025Q1.gpkg and as the release.gpkg of the quick answer; and, as the attacker's auxiliary file (addresses), every UPRN in the same Leicester box from Ordnance Survey's OS Open UPRN (the September 2026 release, 619 MB zipped, Open Government Licence) โ€” the complete, free list of addressable locations in Great Britain. OpenStreetMap holds only 433 address nodes in that box, which would make almost every point look like it had no candidates at all. Overpass via the maps.mail.ru mirror.

import os

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]
features = ox.features_from_polygon(area, tags={"addr:housenumber": True})
brighton = features[features.index.get_level_values(0) == "node"].reset_index(drop=True)
lat, lon = brighton.geometry.y, brighton.geometry.x

WEST, SOUTH, EAST, NORTH = -1.150, 52.625, -1.115, 52.645        # central Leicester
if not os.path.exists("leicester_2025Q1.gpkg"):
    rows = []
    for month in ("2025-01", "2025-02", "2025-03"):
        reply = requests.post("https://data.police.uk/api/crimes-street/all-crime", timeout=120,
                              data={"poly": f"{SOUTH},{WEST}:{SOUTH},{EAST}:{NORTH},{EAST}:{NORTH},{WEST}",
                                    "date": month})
        reply.raise_for_status()
        rows += [{"id": c["id"], "month": c["month"], "category": c["category"],
                  "lon": float(c["location"]["longitude"]), "lat": float(c["location"]["latitude"])}
                 for c in reply.json()]
    table = pd.DataFrame(rows).drop_duplicates("id")
    gpd.GeoDataFrame(table, geometry=gpd.points_from_xy(table.lon, table.lat), crs=4326).to_file("leicester_2025Q1.gpkg")
gpd.read_file("leicester_2025Q1.gpkg").to_file("release.gpkg")

if not os.path.exists("leicester_uprn.gpkg"):
    if not os.path.exists("osopenuprn_csv.zip"):
        with requests.get("https://api.os.uk/downloads/v1/products/OpenUPRN/downloads"
                          "?area=GB&format=CSV&redirect", stream=True, timeout=600) as reply:
            reply.raise_for_status()
            with open("osopenuprn_csv.zip", "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)
    import zipfile
    with zipfile.ZipFile("osopenuprn_csv.zip") as z:
        name = next(n for n in z.namelist() if n.endswith(".csv"))
        with z.open(name) as f:
            keep = [c[c.LONGITUDE.between(WEST, EAST) & c.LATITUDE.between(SOUTH, NORTH)]
                    for c in pd.read_csv(f, chunksize=2_000_000)]
    uprn = pd.concat(keep)
    gpd.GeoDataFrame(uprn[["UPRN"]], crs=27700,
                     geometry=gpd.points_from_xy(uprn.X_COORDINATE, uprn.Y_COORDINATE)).to_file("leicester_uprn.gpkg")
addresses = gpd.read_file("leicester_uprn.gpkg")
print(f"Brighton & Hove: {len(brighton):,} address nodes; Leicester box: {len(addresses):,} UPRNs")
Brighton & Hove: 4,059 address nodes; Leicester box: 39,262 UPRNs

Example 1 โ€” the full uniqueness profile

import numpy as np, pandas as pd, geopandas as gpd

def uniqueness_profile(gdf, quasi_identifiers=(), round_m=1):
    xy = pd.Series(list(zip((gdf.geometry.x / round_m).round().astype(int),
                            (gdf.geometry.y / round_m).round().astype(int))))
    out = {"rows": len(gdf), "distinct_locations": xy.nunique()}
    vc = xy.value_counts()
    out["locations_with_one_record"] = int((vc == 1).sum())
    if quasi_identifiers:
        key = pd.Series(list(zip(xy, *[gdf[c] for c in quasi_identifiers])))
        kvc = key.value_counts()
        out["quasi_id_cells"] = len(kvc)
        out["cells_of_one"] = int((kvc == 1).sum())
        out["records_in_cells_of_one"] = int(kvc[kvc == 1].sum())
    return out

crimes = gpd.read_file("leicester_2025Q1.gpkg").to_crs(27700)
print(uniqueness_profile(crimes, ["month", "category"]))
{'rows': 4013, 'distinct_locations': 368, 'locations_with_one_record': 51, 'quasi_id_cells': 2211, 'cells_of_one': 1501, 'records_in_cells_of_one': 1501}

Example 2 โ€” candidate counts at several radii

from scipy.spatial import cKDTree

tree = cKDTree(np.c_[addresses.geometry.x, addresses.geometry.y])
released = np.c_[pts.geometry.x, pts.geometry.y]

rows = []
for radius in (25, 50, 100, 250, 500):
    k = np.array([len(tree.query_ball_point(p, radius)) for p in released])
    rows.append({"radius_m": radius, "k_min": int(k.min()),
                 "k_median": float(np.median(k)),
                 "below_5": int((k < 5).sum()), "below_10": int((k < 10).sum())})
print(pd.DataFrame(rows).to_string(index=False))
 radius_m  k_min  k_median  below_5  below_10
       25      0      14.0      888      1396
       50      1      68.0      222       355
      100      4     310.0        3         3
      250    113    1889.0        0         0
      500   1265    7298.0        0         0

Report the whole table. A single radius hides the fact that the failing points are the same ones at every radius.

Example 3 โ€” the records that carry the risk

d, _ = cKDTree(released).query(released, k=6)
isolation = d[:, 5]                                  # distance to the 5th nearest released point

worst = pts.assign(isolation_m=isolation).nlargest(20, "isolation_m")
print(worst[["category", "isolation_m"]].round(0).to_string(index=False))
print(f"\n{(isolation > 150).sum()} points are more than 150 m from their 5th nearest neighbour")
             category  isolation_m
        violent-crime        228.0
         public-order        218.0
        violent-crime        186.0
        violent-crime        179.0
        violent-crime        179.0
          other-theft        179.0
         public-order        170.0
        violent-crime        152.0
        vehicle-crime        152.0
                drugs        152.0
         public-order        141.0
        violent-crime        136.0
anti-social-behaviour        136.0
          other-crime        136.0
anti-social-behaviour        136.0
        violent-crime        136.0
          other-crime        136.0
         public-order        135.0
        violent-crime        135.0
        violent-crime        133.0

10 points are more than 150 m from their 5th nearest neighbour

Example 5 โ€” the same audit against a different auxiliary file

features = ox.features_from_bbox((WEST, SOUTH, EAST, NORTH), tags={"addr:housenumber": True})
osm = features[features.index.get_level_values(0) == "node"].to_crs(27700)
for name, aux in [("OS Open UPRN", addresses), ("OpenStreetMap", osm)]:
    k = np.array([len(c) for c in cKDTree(np.c_[aux.geometry.x, aux.geometry.y]).query_ball_point(released, 50)])
    print(f"{name:13s} {len(aux):6,} addresses; crimes with fewer than 5 within 50 m: {(k < 5).sum():,}")
OS Open UPRN  39,262 addresses; crimes with fewer than 5 within 50 m: 222
OpenStreetMap    433 addresses; crimes with fewer than 5 within 50 m: 3,203

Example 4 โ€” the unit table in step 4

snap = pd.Series(list(zip(crimes.geometry.x.round(1), crimes.geometry.y.round(1))), index=crimes.index)
for name, cols in [("snap point", []), ("point ร— month", ["month"]),
                   ("point ร— category", ["category"]), ("point ร— month ร— category", ["month", "category"])]:
    cells = crimes.groupby([snap, *[crimes[c] for c in cols]]).size()
    ones = int((cells == 1).sum())
    print(f"{name:26s} {len(cells):6,} {ones:6,} ({ones / len(cells):.1%})")
snap point                    368     51 (13.9%)
point ร— month                 866    259 (29.9%)
point ร— category            1,517    840 (55.4%)
point ร— month ร— category    2,211  1,501 (67.9%)

Twenty rows is a list a person can read and make a decision about โ€” drop them, generalise them, or accept them. That is what the audit is for.

Explanation

Why uniqueness is measured at the published precision

The source file is always unique; that is not news. What matters is whether the release is unique, so every uniqueness count must be computed after rounding, masking or aggregation, using exactly the values you intend to write.

Why the auxiliary file must be named

The candidate count is a ratio between your points and somebody else's records. In the central Leicester box OpenStreetMap holds 433 address nodes and OS Open UPRN 39,262: against OpenStreetMap, 3,203 of the 4,013 crimes had fewer than five candidates within 50 m; against UPRN, 222 did. The choice of file moved the answer by an order of magnitude, so it has to be stated.

Why the filter cross-product is the real unit

Users do not download the file; they filter the map. The Leicester extract is published as an already-anonymised product and is safe at the level of a bare point. At the level its own map exposes, two-thirds of cells describe one incident, and a person who knows roughly when and what will recover it.

Why risk is reported as a distribution

A mean candidate count of 400 tells you nothing about the point with two candidates, and the point with two candidates is the release. Always report the minimum, the count below the threshold, and the list of offenders.

What a re-identification audit report must record to be reproducible. what the report must saythe CRS every distance was measured inthe auxiliary file and its versionk is a ratiominimum k, not the meanthe filter combinations testedthe failing records, by countnot their coordinatesStore it beside the data, dated spatialworkflow.io
The report is the evidence; it has to be re-runnable a year later.

Edge cases or notes

  • Audit the output of your pipeline, not the input. Masking code with a bug produces an unmasked release that the input-side audit passed.
  • Empty attributes are attributes. A null category can be as distinguishing as a rare one.
  • Duplicated subjects inflate k. Two records for the same person are one candidate.
  • Check the CRS on both layers. A silent CRS mismatch makes every distance meaningless and every k enormous.
  • Sample only for speed, never for the report. The rare points are the ones a sample misses.
  • Re-run after every schema change. Adding a column changes the quasi-identifier set.
  • Keep the audit script in the repository. It is the evidence, and it has to be re-runnable.
  • Include the derived products. A KDE raster and an SVG map are part of the release.

FAQ

What should I measure first?

How many records are alone on their coordinate at the precision you intend to publish. If that number is large, nothing else in the audit matters yet.

Which address file should I use as the attacker's data?

The most complete one you can legally obtain, because that is what an attacker will use. In Great Britain that is OS Open UPRN, which is free. OpenStreetMap is patchy: in central Leicester it held about one address node for every 90 UPRNs, and it made the release look far riskier than it is. That is conservative, but it is not a measurement.

What radius should I count candidates in?

The uncertainty your release admits to: the rounding cell, the masking radius, or the aggregation polygon. Report several radii so the shape of the risk is visible.

Why does my already-anonymised dataset still fail?

Usually because the threshold was applied to the map and not to the filters. Crossing a safe-looking snap point with month and category made 67.9% of cells singletons in the Leicester extract.

Do attributes need auditing too?

Yes. Count how many records share both a neighbourhood and an attribute combination; uniqueness on both is the condition an attacker needs.

How often should the audit run?

On every release, automatically, and again whenever a column is added or a filter is exposed in the UI.