Validating Geomasks with Spatial K-Anonymity
Part of Geographic Masking Techniques, this guide covers the step most masking pipelines skip: proving the mask worked. A displacement radius is an input to privacy, not evidence of it. The defensible metric is spatial k-anonymity — the number of residents who are at least as close to the masked point as the true one — and it must be computed per point against a real population surface, then asserted against a target before any release.
Problem Context & Constraints
Setting a minimum displacement radius feels like it guarantees privacy, but it does not, because privacy depends on people, not distance. Moving a point 500 metres in a dense apartment district places thousands of candidate households between the mask and the truth; the identical 500-metre move on a rural road may leave a single farmhouse as the obvious source. The parameter that protects one point under-protects the other, and the only way to tell them apart is to count who actually lives inside the displacement.
Spatial k-anonymity makes that count the definition. A masked location provides k-anonymity for a true location if at least residents are as close to as is — formally, if the disc centered on the masked point with radius equal to the mask distance contains at least people:
The disc radius is not a free parameter here; it is exactly the realized displacement of that point. Every resident inside the disc is at least as plausible a source as the true patient, so if the disc holds people, the true patient is one of equally-consistent candidates. If it holds one, the mask names them. The broader treatment of this guarantee for microdata releases is in Spatial K-Anonymity for Microdata; here it is applied narrowly as a mask validator.
Three constraints shape a correct validation:
- The disc radius is the mask distance, per point. You need both the true and masked coordinates to compute it — validation is an internal, privileged step that reads true locations but must never log them.
- Population comes from a real surface. A census block/block-group layer with counts, or a gridded population raster, supplies the residents to count; a uniform assumption defeats the purpose.
- The gate is a hard minimum, not an average. A mean achieved of 500 is worthless if one point achieves . The release must be gated on the minimum achieved across all points.
Counting residents inside the displacement disc is the whole validation, and the picture makes the definition concrete.
Prerequisites
python3.11geopandas0.14.4 (pullsshapely2.0.x,pyproj3.6.x)numpy1.26.4
Input state assumed by the code:
- Masked points from the masking step, in a projected metric CRS, each row carrying its masked
geometry, the true coordinatestrue_x/true_yin the same CRS (privileged, never logged), and a stablerecord_key. - A population layer in the same CRS: census blocks or block groups as polygons with a population count column, or a gridded raster. The examples below use a polygon layer with area-weighted apportionment; when finer realism is needed, refine the population surface with land use as in Dasymetric Population Suppression.
Step-by-Step Solution
For each masked point, the validator computes the mask distance, builds the displacement disc, and sums the population it contains by area-weighting each intersecting block. It then asserts the per-point minimum meets the target and reports the achieved distribution — logging only counts, never a true coordinate.
# Per-point spatial k-anonymity validation of a geomask.
# Pinned: geopandas==0.14.4, numpy==1.26.4, shapely==2.0.4, pyproj==3.6.1
import logging
import numpy as np
import geopandas as gpd
from shapely import STRtree
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("geomask_kanon")
def population_in_disc(disc, blocks, tree, block_pop, block_area):
"""Area-weighted population inside a disc, assuming within-block homogeneity."""
# STRtree gives candidate blocks whose bbox intersects the disc.
candidates = tree.query(disc)
total = 0.0
for idx in candidates:
inter = disc.intersection(blocks.geometry.iloc[idx])
if inter.is_empty:
continue
# fraction of the block covered by the disc -> that fraction of its people
total += block_pop[idx] * (inter.area / block_area[idx])
return total
def validate_geomask(masked: gpd.GeoDataFrame, blocks: gpd.GeoDataFrame,
*, target_k: int, pop_col: str = "pop") -> gpd.GeoDataFrame:
"""Compute achieved spatial k per masked point and gate on the minimum."""
if masked.crs != blocks.crs:
raise ValueError("Masked points and population layer must share a CRS.")
block_pop = blocks[pop_col].to_numpy(float)
block_area = blocks.geometry.area.to_numpy(float)
tree = STRtree(blocks.geometry.values)
achieved = np.empty(len(masked))
for i, row in enumerate(masked.itertuples()):
# radius = realized mask distance (uses true coords; never logged)
r = float(np.hypot(row.geometry.x - row.true_x,
row.geometry.y - row.true_y))
disc = row.geometry.buffer(r)
achieved[i] = population_in_disc(disc, blocks, tree, block_pop, block_area)
out = masked.copy()
out["achieved_k"] = achieved
out["k_pass"] = achieved >= target_k
n_fail = int((~out["k_pass"]).sum())
log.info("k-anon validation n=%d target_k=%d min_k=%.1f median_k=%.1f fails=%d",
len(out), target_k, float(achieved.min()),
float(np.median(achieved)), n_fail)
if n_fail:
# Do NOT release: some points are under-protected. Re-mask the failures
# with a larger radius, or aggregate them, before publishing.
log.warning("%d points below target_k — release blocked", n_fail)
return out
The release gate is the min_k line, not the median. A point whose disc holds fewer than target_k residents must be re-masked with a larger radius (see Donut Geomasking for Patient Address Points) or dropped to an aggregate, and only after every point passes may the layer be published.
Validation & Edge Cases
1. Points that fail the floor in low density. The most common failure is a sparse-rural point whose disc, however large, still contains fewer than target_k people. Enlarging the radius helps up to a point, but a genuinely isolated household cannot be made k-anonymous by displacement alone:
INFO k-anon validation n=4820 target_k=25 min_k=3.0 median_k=488.2 fails=11
WARNING 11 points below target_k — release blocked
Eleven points cannot be safely masked at this radius. The correct response is to re-mask those records with a density-adaptive radius or aggregate them to an areal unit — never to lower target_k to make the batch pass, which is fitting the guarantee to the data instead of the data to the guarantee.
2. Within-block homogeneity is an assumption. Area-weighting spreads a block’s population uniformly across its polygon, which overcounts residents in the uninhabited half of a block that is mostly parkland or water. This inflates achieved and can pass a point that is really under-protected. Where blocks are large or heterogeneous, refine the population surface with a dasymetric land-use step before validating, so residents are counted where they actually live.
3. Edge-of-study-area truncation. A disc that extends beyond the population layer’s extent counts only the residents inside the frame, under-estimating near the boundary. That direction is conservative — it fails safe — but it can block otherwise-fine points, so pad the population layer at least one maximum-mask-radius beyond the reporting boundary before validating.
4. Determinism of the check. Validation must be reproducible alongside the mask. Because it reads the masked layer and a fixed population layer with no randomness, re-running it reproduces the same achieved- vector exactly — record the population layer’s version hash so a reviewer validates against the identical surface.
Compliance Notes
- Log counts, not coordinates. The validator reads true locations to compute each disc radius, but only the achieved , the target, the minimum, and the fail count may be written to any log. The true coordinate is used and discarded in memory.
- Gate on the minimum, and keep the evidence. Persist the per-point
achieved_k(or at least the batch minimum) as the auditable proof the guarantee held, alongside the population layer version and the target. This is the counterpart to the mask’s logged seed: together they show both what mask was applied and that it was sufficient. - Block, don’t downgrade. A failing batch must halt the release. Lowering
target_kto clear the gate converts a documented under-protection into a silent one and is the single most damaging shortcut in a disclosure workflow.
Validation closes the loop opened by masking: the seed and radii prove the mask is reproducible, and the achieved proves it is private. Only with both in the audit record is a point-level health release defensible.
Related Topics
- Spatial K-Anonymity for Microdata — the general guarantee this page applies specifically as a mask validator.
- Donut Geomasking for Patient Address Points — produces the masked points and the radii this validation checks.
- Geographic Masking Techniques — the parent guide on displacement families and the privacy-utility tradeoff.