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.
The inner radius is the entire point of the method, and its absence is what makes plain perturbation unsafe rather than merely weaker:
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.
Displacement must respect the geography it moves through, or it produces records that are both private and impossible:
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.