Donut Geomasking for Patient Address Points
This guide, part of Geographic Masking Techniques, solves one specific weakness of naive geomasking: a simple random displacement can leave a masked patient address point on the true location or close enough that the household is still obvious. Donut masking fixes it structurally by forbidding any displacement shorter than a minimum radius while still capping it at a maximum, so every point is guaranteed to move a defensible distance.
Problem Context & Constraints
The intuitive way to mask a point is to add a random offset drawn uniformly from a disc of some radius . It seems safe — the point moves in a random direction by a random amount — but it has a defect that is easy to miss and fatal in a disclosure review. A disc includes its own center, and the probability density of the distance from center under a uniform-area draw grows with radius, yet it is still nonzero all the way down to zero. Sample enough points and some receive a displacement of a few metres; a handful land effectively on top of the true address. A half-normal (Gaussian) displacement is worse: its modal distance is exactly zero, so the single most likely outcome is almost no movement at all.
For patient address points this is not a cosmetic flaw. A masked case that sits ten metres from the true residence, in a neighborhood the adversary can already narrow down, re-identifies the patient as surely as no masking at all. The naive method fails because it controls the distribution of displacement without enforcing a floor on it. What a defensible mask needs is a guaranteed minimum move.
Donut masking supplies exactly that. It draws the displacement distance from an annulus — a ring with inner radius and outer radius — so no point can move less than (the privacy floor) or more than (the utility cap). The direction stays uniform on . Three constraints shape a correct implementation:
- The radius must be area-correct. Drawing uniformly on over-concentrates points near the inner edge, because a thin ring at large radius has more area than one at small radius. The displacement must be sampled so points spread evenly across the ring.
- Distances must be metric. Displacement in degrees of latitude/longitude is not a fixed distance — it shrinks toward the poles — so and are only meaningful in a projected CRS measured in metres.
- The run must be reproducible. A disclosure reviewer has to regenerate the exact masked points, which means a single logged random seed and logged /, and a stable input order.
The geometry of a single donut displacement makes the guarantee visible: the masked point is confined to the shaded ring, never inside the inner circle.
Prerequisites
python3.11geopandas0.14.4 (pullsshapely2.0.x,pyproj3.6.x)numpy1.26.4
Input state assumed by the code:
- Case points as a point
GeoDataFramecarrying a stable, non-patientrecord_key— never a raw medical record number or name. - A projected CRS in metres. Reproject before masking so and are true metres; align to your jurisdiction’s standard as covered in Coordinate Reference Systems for Public Health. EPSG:5070 (CONUS Albers) or the local UTM zone are typical.
- Parameters from configuration, not literals:
r_min,r_max, and a single integerseed.
Step-by-Step Solution
The sampler below reprojects to an equal-area CRS, draws a uniform bearing and an area-correct annulus radius, applies the offset, and logs the seed and radii — never a true coordinate. The radial draw uses the inverse-CDF for a ring, with , which is the density-correct weighting that spreads points evenly over the annulus.
# Deterministic donut geomasking for patient address points.
# 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.geometry import Point
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("donut_geomask")
def donut_geomask(cases: gpd.GeoDataFrame, *, seed: int, r_min: float,
r_max: float, equal_area_crs: str = "EPSG:5070") -> gpd.GeoDataFrame:
"""Displace each point onto the annulus [r_min, r_max] with a logged seed.
Returns a GeoDataFrame in equal_area_crs with masked geometry and the
realized displacement distance per point (for validation), plus an audit
log line carrying only the seed and radii.
"""
if r_min <= 0 or r_max <= r_min:
raise ValueError("Require 0 < r_min < r_max (metres).")
if cases.crs is None:
raise ValueError("Input has no CRS; refusing to mask in unknown units.")
# 1. Project to metres so r_min / r_max are true distances.
cases = cases.to_crs(equal_area_crs)
# 2. Stable order => the seeded draw is reproducible.
cases = cases.sort_values("record_key").reset_index(drop=True)
rng = np.random.default_rng(seed) # single, logged source of randomness
n = len(cases)
theta = rng.random(n) * 2.0 * np.pi # uniform bearing
u = rng.random(n)
# 3. Area-correct annulus radius: no point closer than r_min, none past r_max.
r = np.sqrt(u * (r_max**2 - r_min**2) + r_min**2)
dx, dy = r * np.cos(theta), r * np.sin(theta)
masked = cases.copy()
masked["geometry"] = gpd.GeoSeries(
[Point(x + ox, y + oy)
for x, y, ox, oy in zip(cases.geometry.x, cases.geometry.y, dx, dy)],
crs=equal_area_crs,
)
masked["displacement_m"] = r # kept for the r >= r_min assertion
# 4. Audit record: seed and radii only — never a true coordinate.
log.info("donut_geomask n=%d seed=%d r_min=%.1f r_max=%.1f "
"min_move=%.1f max_move=%.1f", n, seed, r_min, r_max,
float(r.min()), float(r.max()))
return masked
Because the generator is seeded and the input is sorted, a reviewer who re-runs the function with the same seed, r_min, and r_max obtains byte-identical masked coordinates. That single property — reproducibility from a logged seed — is what turns a random operation into an auditable one.
Validation & Edge Cases
A donut mask is only trustworthy if you prove its guarantee held. Three checks catch the failures that matter.
1. No point moved less than . This is the whole point of the method, so assert it rather than assume it. The realized distances are already stored:
def assert_min_displacement(masked, r_min, tol=1e-6):
"""Hard gate: every point must have moved at least r_min."""
below = masked.loc[masked["displacement_m"] < r_min - tol]
if len(below):
raise AssertionError(
f"{len(below)} points moved < r_min ({r_min} m) — mask invalid, do not release")
return True
A failure here almost always means the radius was sampled with the wrong formula (uniform on instead of the square-root form), or that masking ran on unprojected degrees so the “metres” were actually tiny angular units.
2. The displacement distribution matches the annulus. A correct sampler yields a realized-distance histogram that rises across the ring rather than spiking at ; a quick moment check flags a mis-weighted draw:
INFO donut_geomask n=4820 seed=20260713 r_min=250.0 r_max=1500.0 min_move=250.4 max_move=1499.1
INFO displacement summary: mean=1007.3 median=1043.0 p05=372.1 p95=1458.6
If the mean sits near and the median barely above it, the radius weighting is wrong — the ring is not being filled uniformly and points are piling up next to the forbidden zone.
3. Coastal and uninhabitable landings. A uniformly chosen bearing near a shoreline can push a masked point into water or onto unpopulated land, which is both implausible and a subtle disclosure signal (an adversary discards impossible locations, shrinking the candidate set). The fallback is to reject and resample that point against a habitable-land mask, capping the attempts:
from shapely.geometry import Point
def resample_if_uninhabitable(x, y, rng, r_min, r_max, habitable, max_tries=25):
"""Redraw bearing/radius until the point lands on habitable land."""
for _ in range(max_tries):
theta = rng.random() * 2.0 * np.pi
r = np.sqrt(rng.random() * (r_max**2 - r_min**2) + r_min**2)
cx, cy = x + r * np.cos(theta), y + r * np.sin(theta)
if habitable.contains(Point(cx, cy)):
return cx, cy, r
return cx, cy, r # exhausted: caller flags for manual review
Do not “solve” a coastal point by shrinking for it — that quietly weakens the privacy floor exactly where it is already hard to satisfy. Flag it for review instead.
Compliance Notes
Three artifacts make a donut-masked release defensible:
- Seed and radii, never coordinates. Log the integer
seed,r_min,r_max, and the CRS authority string. These regenerate the exact masked points for a reviewer without persisting any true patient location, satisfying minimum-necessary and data-minimization obligations. - The realized minimum displacement. Record
min_movefrom the audit line as evidence the floor held for the whole batch — the machine-checked counterpart to the parameter you set. - Determinism. The sorted
record_keyorder plus the single seeded generator guarantee an identical re-run, which is the precondition for peer review and interagency handoff.
Enforcing the floor is necessary but not sufficient: a large in a dense downtown still under-protects if the surrounding population is unusual. Confirm the actual privacy guarantee — how many residents are plausibly closer to the mask than the truth — as shown in Validating Geomasks with Spatial K-Anonymity.
Related Topics
- Geographic Masking Techniques — the parent guide comparing donut masking with random, Gaussian, and population-adaptive displacement.
- Validating Geomasks with Spatial K-Anonymity — proving the achieved privacy guarantee the minimum radius alone cannot certify.
- Privacy-Preserving Spatial Analytics — where point masking sits among the four disclosure-control method families.