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 xx' provides k-anonymity for a true location xx if at least kk residents are as close to xx' as xx is — formally, if the disc centered on the masked point with radius equal to the mask distance contains at least kk people:

k(x)={p:d(p,x)d(x,x)}ktarget.k(x') = \big|\{\,p : d(p, x') \le d(x, x')\,\}\big| \ge k_{\text{target}}.

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 kk people, the true patient is one of kk 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 kk of 500 is worthless if one point achieves k=2k=2. The release must be gated on the minimum achieved kk across all points.

Counting residents inside the displacement disc is the whole validation, and the picture makes the definition concrete.

Counting Population Within the Displacement Disc A masked point sits at the center of a disc whose radius equals the distance to the true location, which lies on the disc boundary. Resident dots fall inside and outside the disc. The residents inside the disc are the ones at least as close to the masked point as the true patient, and their count is the achieved spatial k-anonymity. Here seven residents fall inside, so k equals seven, which must meet or exceed the target. masked point x' r = d(x', true) true location residents inside the disc = achieved k (here 7) — must be ≥ target inside disc (counted) outside (not counted)

Prerequisites

  • python 3.11
  • geopandas 0.14.4 (pulls shapely 2.0.x, pyproj 3.6.x)
  • numpy 1.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 coordinates true_x/true_y in the same CRS (privileged, never logged), and a stable record_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 kk 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 kk 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-kk 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 kk, 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_k to 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 kk proves it is private. Only with both in the audit record is a point-level health release defensible.