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

The inner radius is the entire point of the method, and its absence is what makes plain perturbation unsafe rather than merely weaker:

The Spike Near Zero That Donut Masking Removes Two displacement-distance distributions. Under uniform perturbation within a five-hundred-metre radius, a meaningful share of points land within fifty metres of their true location — for those cases the mask has done nothing. Under donut masking with an inner radius of one hundred and fifty metres, that region is empty by construction and the distribution begins at the inner radius. The area under both curves is the same; only the left tail differs, and the left tail is where re-identification happens. The left tail is the whole risk, and donut masking deletes it unmasked in practice inner radius, 150 m donut uniform 0 150 m 400 m 700 m displacement distance Both distributions have the same median; only one of them has no unmasked cases

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.

Displacement must respect the geography it moves through, or it produces records that are both private and impossible:

Three Places a Masked Point Must Not Land A county boundary with a lake and an industrial zone inside it. Three masked points have landed implausibly: one in the lake, one across the county line, and one on the industrial site. Each is redrawn under a constrained resample that rejects candidate positions outside habitable land and outside the reporting unit, so the released point remains both displaced and plausible. A point that crosses the county line is the most damaging of the three because it corrupts the denominator as well as the location. A private point that could not exist is still a data-quality defect reporting county lake industrial outside the county wrong denominator too rejected draw accepted resample Constrain the resample to habitable land inside the reporting unit, and log the rejection rate a rejection rate above about 30% means the radius is too large for this geography

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.