Enforcing Spatial K-Anonymity on Case-Point Releases
Publishing a case-point map can silently violate k-anonymity in low-density areas even after coordinates are jittered, because the disc that actually contains people may dwarf the jitter radius. This guide, part of spatial k-anonymity for health microdata, computes the smallest population-bearing disc around each point, keeps points whose disc stays tight, and snaps the rest to a coarser geography with a suppression report you can defend.
Problem Context & Constraints
The naive point release — take each case, add a random offset of a few hundred metres, plot it — feels private because the marker no longer sits on the patient’s roof. But privacy is not measured in metres of displacement; it is measured in people. A 250 m jitter around an address in a dense urban block might enclose two thousand residents, comfortably anonymous. The identical jitter around a farmhouse might enclose four. The offset is the same; the anonymity set differs by three orders of magnitude, and only the second case is a re-identification waiting to happen.
Three constraints make point releases harder than areal counts:
- Jitter radius and anonymity set are decoupled. A fixed displacement gives a fixed geometric uncertainty but a wildly variable population uncertainty. Uniform jitter under-protects exactly where protection matters most — the sparse periphery — and over-protects the dense core where it is least needed.
- Density varies continuously across one map. A single global radius cannot be correct everywhere. The radius that reaches people is a property of the location, so it must be computed per point, not assumed.
- Points that cannot be protected must not be dropped. A case that fails the test still happened; deleting it biases the surface toward densely populated places. The correct move is to coarsen its geography — represent it as a larger unit — not to erase it.
The fix replaces a global jitter with a per-point, population-aware rule: grow a disc around each point until it holds people, and if that disc is unacceptably large, stop representing the case as a point and aggregate it up to a unit that clears the threshold. This is a natural companion to donut geomasking for patient address points, which controls where a displaced point lands; here we decide whether a point may be published at all.
Prerequisites
Pin the stack so the smallest-disc computation and the resulting release are reproducible:
python3.11geopandas0.14.4 (pullsshapely2.0.x,pyproj3.6.x)numpy1.26.4,scipy1.13.1 —cKDTreefor radius queries
Input state assumed below:
- Case points as a point
GeoDataFramewith stable, non-patient identifiers and a defined CRS. - Population basis as blocks (or a raster converted to weighted centroids) carrying the at-risk count matching the case definition — the same denominator discipline the parent section insists on.
- CRS: one projected, area-appropriate CRS for all layers so a radius is measured in metres. Reproject before any distance query; getting WGS84 and a metric grid aligned first is covered in coordinate reference systems for public health.
- A declared and a maximum acceptable radius , both from written policy. is the line past which a “point” is too vague to be worth publishing as a point.
Step-by-Step Solution
For each case point, sort nearby population cells by distance, accumulate their at-risk population outward, and record as the radius at which the running total first reaches . If the point is releasable (optionally masked within ); if the case is snapped to the coarse unit that contains it. Every decision is logged, and a suppression report tallies what was coarsened.
# Per-point smallest-disc-to-k, with snap-to-coarse fallback and a suppression report.
# python 3.11
# geopandas==0.14.4 shapely==2.0.4 pyproj==3.6.1
# numpy==1.26.4 scipy==1.13.1
import logging
import numpy as np
import geopandas as gpd
from scipy.spatial import cKDTree
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("k_point")
EQUAL_AREA = "EPSG:5070" # metric, area-faithful — radii are in metres
def smallest_disc_radius(case_xy, pop_tree, pop_xy, pop_at_risk, k_min, r_hard=50_000):
"""
Radius at which cumulative at-risk population around a case first reaches k_min.
Queries neighbors out to a hard cap; returns np.inf if k_min is unreachable.
"""
# candidate cells within the hard cap, nearest first
idx = pop_tree.query_ball_point(case_xy, r=r_hard)
if not idx:
return np.inf
d = np.linalg.norm(pop_xy[idx] - case_xy, axis=1)
order = np.argsort(d, kind="stable") # deterministic ordering
cum = np.cumsum(pop_at_risk[idx][order])
reach = np.searchsorted(cum, k_min) # first index where cum >= k_min
if reach >= len(cum):
return np.inf # not enough people even at the cap
return float(d[order][reach])
def enforce_point_k(cases, pop, coarse, k_min, r_max, geoid="geoid",
pop_field="pop_at_risk", coarse_id="tract_id"):
"""
cases : point GeoDataFrame (release candidates)
pop : population basis (points or block centroids) with pop_field
coarse: coarse polygons (e.g. tracts) with coarse_id — the snap target
Returns (released_points, aggregated_report).
"""
for name, gdf in (("cases", cases), ("pop", pop), ("coarse", coarse)):
if gdf.crs is None:
raise ValueError(f"{name} has no CRS; refuse to measure radii on undefined units.")
cases = cases.to_crs(EQUAL_AREA).sort_values(geoid).reset_index(drop=True)
pop = pop.to_crs(EQUAL_AREA)
coarse = coarse.to_crs(EQUAL_AREA)
pop_xy = np.column_stack([pop.geometry.x, pop.geometry.y])
pop_pts = pop[pop_field].to_numpy(float)
tree = cKDTree(pop_xy)
radii, keep_mask = [], []
for _, row in cases.iterrows():
cxy = np.array([row.geometry.x, row.geometry.y])
r_i = smallest_disc_radius(cxy, tree, pop_xy, pop_pts, k_min)
radii.append(r_i)
keep_mask.append(r_i <= r_max) # releasable as a point?
cases["disc_radius_m"] = radii
cases["releasable"] = keep_mask
released = cases[cases["releasable"]].copy()
to_snap = cases[~cases["releasable"]].copy()
# snap unreachable cases to the coarse unit that contains them, then count per unit
if len(to_snap):
snapped = gpd.sjoin(to_snap, coarse[[coarse_id, "geometry"]],
predicate="within", how="left")
report = (snapped.groupby(coarse_id)
.size().rename("aggregated_cases").reset_index())
else:
report = coarse[[coarse_id]].iloc[0:0].assign(aggregated_cases=0)
log.info("released_points=%d snapped_cases=%d coarse_units=%d k_min=%d r_max=%.0fm",
len(released), len(to_snap), report.shape[0], k_min, r_max)
return released, report
The contract mirrors the areal case: nothing is deleted and nothing is imputed. A point that clears is published (optionally after masking anywhere inside its own , which is guaranteed anonymous by construction); a point that fails is represented honestly as a coarse-unit count. The disc_radius_m column is retained because it is the achieved-privacy measurement — a reviewer can confirm the guarantee from the output alone.
Validation & Edge Cases
A released layer needs three assertions before it leaves the pipeline.
1. Minimum-k assertion on released points. By construction every released point had , but a bug in the population join can quietly zero the denominator. Re-derive the enclosed population for a sample and assert it clears the threshold:
def assert_min_k(released, pop, k_min, pop_field="pop_at_risk"):
tree = cKDTree(np.column_stack([pop.geometry.x, pop.geometry.y]))
pop_pts = pop[pop_field].to_numpy(float)
worst = np.inf
for _, row in released.iterrows():
idx = tree.query_ball_point([row.geometry.x, row.geometry.y], r=row["disc_radius_m"] + 1)
worst = min(worst, float(pop_pts[idx].sum()))
assert worst >= k_min, f"released point below k_min: enclosed pop {worst:.0f} < {k_min}"
return worst
2. Count of aggregated points reconciles. Released points plus snapped cases must equal the input count exactly — a mismatch means a case fell through a null join and vanished:
INFO enforce_point_k: released_points=4127 snapped_cases=619 coarse_units=88 k_min=5 r_max=2000m
assert len(released) + int(report["aggregated_cases"].sum()) == len(cases), \
"case accounting mismatch: a point was dropped, not coarsened"
3. Snap target actually contains the point. A left join to coarse polygons produces null tract_id for any case that falls in a gap between polygons (a coastline sliver, a boundary crack). Those nulls must be caught and routed to review, never released as an unlabeled aggregate. A wall of nulls almost always signals a CRS mismatch or an incomplete coarse layer rather than genuinely off-map cases.
Two further failure modes recur in practice. First, an unreachable returns — the case sits in a region so sparse that even the hard query cap holds fewer than people; these must snap to coarse geography and, if the coarse unit itself is below threshold, escalate to the areal aggregation in the parent guide. Second, clustered cases inflate false confidence: two cases at the same address each individually reach , but publishing both points at once narrows the joint anonymity set, so co-located cases should share one masked location or be aggregated together.
Compliance Notes
Three artifacts make a point release defensible:
- The per-point radius, retained in the output.
disc_radius_mis the auditable proof that each published point met the population threshold; it lets a reviewer reconstruct the guarantee without re-running the whole pipeline, and because it varies per point it also documents where the map is precise and where it is deliberately vague. - The suppression report. The count of cases snapped to each coarse unit is the record of what was not published as a point. That tally is the difference between an honest release and one that quietly biases toward dense areas by deleting sparse cases.
- Pinned parameters and vintage. Store , , the population layer identity and vintage, and library versions alongside the release. Two runs under different values are different disclosure decisions and must not be compared as if equivalent.
Once a masked point layer exists, close the loop by re-measuring it: the achieved anonymity of the final map should be confirmed with the same population-based test used across this section, as detailed in validating geomasks with spatial k-anonymity. A mask you have not validated against a denominator is an assumption, not a guarantee.
Related Topics
- Spatial K-Anonymity for Health Microdata — the parent guide and the areal-count counterpart to this point-release method.
- Donut Geomasking for Patient Address Points — controls where a displaced point lands within the anonymous disc.
- Validating Geomasks with Spatial K-Anonymity — re-measures the achieved anonymity of the released point layer.