Donut Masking vs. Gaussian Displacement
Donut masking guarantees every point moves at least r_min by drawing its displacement from an annulus, whereas Gaussian or uniform jitter leaves a fraction of points barely displaced and therefore trivially re-identifiable — so choose the donut whenever a minimum-shift floor is the requirement.
Core Calculation permalink
Under coordinate jittering and noise injection, donut masking offsets a point by a vector with a uniformly random direction and a radius confined to an annulus :
The square root makes the draw uniform over the annulus area rather than over the radius, so points do not pile up against the inner ring. The displaced point is:
The expected displacement of an area-uniform donut is:
The defining property is the hard floor: . By contrast, Gaussian displacement adds independent normal noise per axis:
whose radial displacement follows a Rayleigh distribution with mean and, critically, for any . A meaningful share of Gaussian-masked points barely move.
| Property | Donut masking | Gaussian displacement |
|---|---|---|
| Minimum displacement | Guaranteed r_min |
None (mass near 0) |
| Radial law | Area-uniform on annulus | Rayleigh |
| Mean displacement | ||
| Formal DP guarantee | No | Only with calibrated |
| Best for | Explainable minimum shift | Smooth, unbounded noise |
Worked numeric example permalink
- Donut: m, m
- Expected displacement: m
- Gaussian matched to the same 560 m mean: m
With that Gaussian, — roughly 10% of points move less than 200 m, and some land essentially on top of their true location. The donut moves zero points under 200 m by construction.
Python Implementation permalink
Both masks are applied in a projected metric CRS (a local UTM zone) so radii are in meters, then reprojected to WGS84. Input is EPSG:4326.
import numpy as np
import geopandas as gpd
from typing import Literal
def mask_points(
points: gpd.GeoDataFrame,
method: Literal["donut", "gaussian"],
r_min: float = 200.0,
r_max: float = 800.0,
sigma: float = 447.0,
metric_crs: str = "EPSG:32633", # local UTM zone; choose the one for YOUR data
seed: int | None = None,
) -> gpd.GeoDataFrame:
"""
Displace points by donut masking or Gaussian jitter in a metric CRS.
Privacy rationale: donut masking guarantees every point moves at least
r_min meters, closing the tiny-shift re-identification gap that plain
Gaussian noise leaves open. Gaussian jitter is smoother but places
probability mass arbitrarily close to zero displacement.
Args:
points: Point GeoDataFrame in EPSG:4326.
method: "donut" (minimum-shift annulus) or "gaussian" (unbounded).
r_min, r_max: Inner/outer annulus radii in meters (donut only).
sigma: Per-axis Gaussian standard deviation in meters (gaussian only).
metric_crs: Projected CRS so offsets are in meters.
seed: Optional RNG seed for reproducibility.
Returns:
A copy of `points` reprojected to EPSG:4326 with displaced geometry.
"""
if method == "donut" and not (0 < r_min < r_max):
raise ValueError("Require 0 < r_min < r_max for donut masking.")
rng = np.random.default_rng(seed)
proj = points.to_crs(metric_crs).copy() # meters
x = proj.geometry.x.to_numpy()
y = proj.geometry.y.to_numpy()
n = len(proj)
if method == "donut":
# Uniform angle; area-weighted radius so the ring fills evenly and,
# crucially, r is NEVER below r_min -> guaranteed minimum displacement.
theta = rng.uniform(0.0, 2.0 * np.pi, n)
r = np.sqrt(rng.uniform(r_min**2, r_max**2, n))
dx, dy = r * np.cos(theta), r * np.sin(theta)
else: # gaussian
# Independent zero-mean normal noise per axis: smooth but no floor,
# so a share of points barely move (the re-identification weakness).
dx = rng.normal(0.0, sigma, n)
dy = rng.normal(0.0, sigma, n)
proj = proj.set_geometry(gpd.points_from_xy(x + dx, y + dy, crs=metric_crs))
return proj.to_crs("EPSG:4326")
Key decisions:
- Metric CRS first:
r_min,r_max, andsigmaare meters, which is only meaningful in a projected CRS; masking in EPSG:4326 would treat degrees as meters and distort displacement by latitude. - Area-weighted radius:
sqrt(uniform(r_min**2, r_max**2))yields an area-uniform annulus so points do not cluster on the inner ring, which would concentrate them near the minimum shift. - Explicit floor: because the radius is sampled from
[r_min, r_max], the donut’s minimum-displacement guarantee is structural, not statistical.
Verification permalink
import numpy as np
import geopandas as gpd
def verify_masks(original: gpd.GeoDataFrame, masked: gpd.GeoDataFrame,
metric_crs: str, r_min: float) -> dict:
"""Confirm the donut floor and summarize the displacement distribution."""
o = original.to_crs(metric_crs)
m = masked.to_crs(metric_crs)
disp = np.hypot(m.geometry.x.values - o.geometry.x.values,
m.geometry.y.values - o.geometry.y.values)
# Hard guarantee for donut masking: no point moved less than r_min.
assert disp.min() >= r_min - 1e-6, f"Donut floor violated: {disp.min():.1f} m"
return {
"min_displacement_m": round(float(disp.min()), 1),
"mean_displacement_m": round(float(disp.mean()), 1),
"frac_below_r_min": round(float((disp < r_min).mean()), 4),
}
# Donut expectation (r_min=200, r_max=800): min >= 200, mean ~= 560, frac_below = 0.0
# Gaussian expectation (sigma=447): min ~= 0, mean ~= 560, frac_below ~= 0.10
Running verify_masks on the donut output returns frac_below_r_min == 0.0; running it on the Gaussian output (with the assertion relaxed) returns roughly 0.10, quantifying exactly the re-identification gap the donut closes.
Edge Cases & Adjustments permalink
- Density-adaptive radius: a fixed annulus over-protects dense cities and under-protects sparse rural points. Scale
r_minandr_maxinversely with local population density so displacement grows where crowds thin — the same density-driven reasoning used when tuning coordinate jittering parameters for urban density. - Directional bias near coastlines and borders: an isotropic donut can push a coastal point into the sea, which both looks wrong and narrows the plausible true location back onto land. Reject-and-resample displacements that land in an implausible region, or constrain the angle to a land-facing sector.
- Formal guarantees: donut masking has no epsilon bound and degrades under repeated releases of the same point. When you need a provable guarantee, use the calibrated Gaussian or Laplace mechanism for coordinate data instead of, or layered on top of, the donut.
- CRS at wide extents: a single UTM zone distorts distance far from its central meridian. For continental datasets, mask per UTM zone or use an equal-distance projection appropriate to the region so radii stay accurate.
FAQ permalink
Why does a minimum displacement matter?
Gaussian and uniform jitter put substantial probability mass near zero displacement, so a share of points barely move. For those points an attacker who knows the masking distribution can recover the true location because the masked point sits almost on top of it. Donut masking forbids the inner disk entirely, so no point is trivially recoverable by assuming it barely moved.
How do I choose the radius so the donut is uniform over its area?
Sampling the radius uniformly between r_min and r_max concentrates points near the inner ring because the annulus has more area at larger radii. To spread points evenly, draw r as the square root of a uniform sample scaled between r_min² and r_max². This produces an area-uniform donut rather than a radius-uniform one.
Is donut masking differentially private?
No. Donut masking is a heuristic geometric mask with a guaranteed minimum shift, not a formal mechanism. It provides no epsilon guarantee and can leak under repeated releases of the same point. When you need a provable bound, use the Laplace or Gaussian mechanism from differential privacy; reach for the donut when the goal is a simple, explainable displacement floor.
Should the radius adapt to local density?
Yes for many datasets. A fixed radius over-protects dense cities and under-protects sparse rural points where even a large shift may still isolate an individual. Scaling r_min and r_max to local density, so sparse areas receive larger displacement, keeps re-identification risk roughly uniform across the map.
Related
- Coordinate Jittering & Noise Injection Methods — parent guide on displacement-based masking
- Tuning Coordinate Jittering Parameters for Urban Density — density-adaptive radius and sigma selection
- Applying Laplace Noise to Latitude/Longitude Pairs — the differentially private alternative with a formal guarantee
- Geospatial Masking & Perturbation Techniques — the full family of masking methods and when each applies