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 RR. 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 rminr_{\min} and outer radius rmaxr_{\max} — so no point can move less than rminr_{\min} (the privacy floor) or more than rmaxr_{\max} (the utility cap). The direction stays uniform on [0,2π)[0, 2\pi). Three constraints shape a correct implementation:

  • The radius must be area-correct. Drawing rr uniformly on [rmin,rmax][r_{\min}, r_{\max}] 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 rminr_{\min} and rmaxr_{\max} 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 rminr_{\min}/rmaxr_{\max}, 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.

Donut Displacement Geometry A true patient location sits at the center. An inner circle of radius r-min marks the forbidden zone no masked point may enter. An outer dashed circle of radius r-max caps the displacement. The ring between them is shaded and labelled the allowed band. A displacement vector leaves the center at a uniformly random bearing theta and ends at a masked point lying within the ring. The caption gives the area-correct radius formula, which spreads masked points evenly across the ring and never inside r-min. annulus = allowed band true location r_min r_max θ masked point bearing θ ~ Uniform(0, 2π) r = √( U(r_max² − r_min²) + r_min² ), U ~ Uniform(0,1) Points land uniformly across the ring — never inside r_min, never past r_max

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:

  • Case points as a point GeoDataFrame carrying a stable, non-patient record_key — never a raw medical record number or name.
  • A projected CRS in metres. Reproject before masking so rminr_{\min} and rmaxr_{\max} 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 integer seed.

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, r=U(rmax2rmin2)+rmin2r = \sqrt{U\,(r_{\max}^2 - r_{\min}^2) + r_{\min}^2} with UUniform(0,1)U \sim \mathrm{Uniform}(0,1), 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 rminr_{\min}. 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 [rmin,rmax][r_{\min}, r_{\max}] 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 rminr_{\min}; 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 rminr_{\min} 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 rminr_{\min} 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_move from the audit line as evidence the rminr_{\min} floor held for the whole batch — the machine-checked counterpart to the parameter you set.
  • Determinism. The sorted record_key order 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 rminr_{\min} 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.