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.
Spatial k-anonymity is measured, not assumed, and the measurement is a count of plausible households rather than a distance:
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.
The validation report is what a reviewer reads, and one distributional summary answers the question they will actually ask:
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.