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 [rmin,rmax][r_{\min}, r_{\max}]:

θUniform(0,2π),rUniform(rmin2,rmax2)\theta \sim \mathrm{Uniform}(0, 2\pi), \qquad r \sim \sqrt{\mathrm{Uniform}(r_{\min}^2,\, r_{\max}^2)}

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:

x=x+rcosθ,y=y+rsinθx' = x + r\cos\theta, \qquad y' = y + r\sin\theta

The expected displacement of an area-uniform donut is:

E[r]=23rmax3rmin3rmax2rmin2\mathbb{E}[r] = \frac{2}{3}\cdot\frac{r_{\max}^3 - r_{\min}^3}{r_{\max}^2 - r_{\min}^2}

The defining property is the hard floor: Pr[r<rmin]=0\Pr[r < r_{\min}] = 0. By contrast, Gaussian displacement adds independent normal noise per axis:

x=x+N(0,σ2),y=y+N(0,σ2)x' = x + \mathcal{N}(0, \sigma^2), \qquad y' = y + \mathcal{N}(0, \sigma^2)

whose radial displacement follows a Rayleigh distribution with mean σπ/2\sigma\sqrt{\pi/2} and, critically, Pr[r<rmin]>0\Pr[r < r_{\min}] > 0 for any rmin>0r_{\min} > 0. 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 23rmax3rmin3rmax2rmin2\tfrac{2}{3}\frac{r_{\max}^3-r_{\min}^3}{r_{\max}^2-r_{\min}^2} σπ/2\sigma\sqrt{\pi/2}
Formal DP guarantee No Only with calibrated σ\sigma
Best for Explainable minimum shift Smooth, unbounded noise

Worked numeric example permalink

  • Donut: rmin=200r_{\min} = 200 m, rmax=800r_{\max} = 800 m
  • Expected displacement: 238003200380022002=23512,000,0008,000,000640,00040,000=23504,000,000600,000=23×840=560\frac{2}{3}\cdot\frac{800^3 - 200^3}{800^2 - 200^2} = \frac{2}{3}\cdot\frac{512{,}000{,}000 - 8{,}000{,}000}{640{,}000 - 40{,}000} = \frac{2}{3}\cdot\frac{504{,}000{,}000}{600{,}000} = \frac{2}{3}\times 840 = 560 m
  • Gaussian matched to the same 560 m mean: σ=560/π/2447\sigma = 560 / \sqrt{\pi/2} \approx 447 m

With that Gaussian, Pr[r<200]=1exp(2002/(24472))0.10\Pr[r < 200] = 1 - \exp(-200^2 / (2\cdot447^2)) \approx 0.10 — 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.

Donut masking annulus versus Gaussian displacement cloud On the left, a true point sits at the center of a forbidden inner disk of radius r_min and an allowed annulus out to r_max, so every masked point lands in the ring. On the right, a Gaussian cloud places some masked points very close to the true point. Donut masking allowed ring forbidden r_min r_max Gaussian some points barely move
Donut masking confines every displacement to the annulus, enforcing a minimum shift; Gaussian noise lets some masked points land on top of the true location.

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, and sigma are 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_min and r_max inversely 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

Back to Coordinate Jittering & Noise Injection Methods