Calibrating Gaussian Noise for Spatial Aggregates

Calibrating the Gaussian mechanism for a spatial aggregate means computing the L2 sensitivity Δ2\Delta_2 of a vector-valued query — a per-cell count histogram, a spatial sum, a bounded coordinate mean — and then solving for the smallest noise scale σ\sigma that satisfies (ε,δ)(\varepsilon, \delta)-differential privacy across the whole vector at once. Unlike the planar Laplace mechanism, which perturbs a single point under an L1 or metric guarantee, the Gaussian mechanism adds independent N(0,σ2)\mathcal{N}(0, \sigma^2) noise to every component of an aggregate and pays for the Euclidean distance between neighbouring outputs. That distinction is what makes it the natural fit for the high-dimensional releases produced under differentially private spatial aggregation. This guide sits inside the spatial noise mechanisms guide of the Differential Privacy for Geospatial Data section, and it is narrow on purpose: how to bound Δ2\Delta_2, why the textbook σ\sigma bound wastes utility, and how to reach the tight scale through the analytic Gaussian mechanism and ρ\rho-zCDP.

Parameter Configuration and Calibration

The Gaussian mechanism has one output knob, σ\sigma, but that knob is a function of four inputs you must fix first. Get any one wrong and the release is either non-private or needlessly destroyed by noise.

Knob Symbol Typical value Consequence
L2 sensitivity Δ2\Delta_2 11, c\sqrt{c}, or clip bound CC Scales σ\sigma linearly; the whole game is bounding it
Privacy loss ε\varepsilon 0.50.533 per release Smaller ε\varepsilon = more noise = stronger privacy
Failure probability δ\delta 1/N\ll 1/N (e.g. 10610^{-6}) Probability the guarantee silently fails
zCDP budget ρ\rho derived per release Cleaner composition currency than (ε,δ)(\varepsilon,\delta)
Contribution cap cc or CC dataset-specific Enforces the Δ2\Delta_2 you assumed

Computing Δ2\Delta_2 for spatial queries. L2 sensitivity is the maximum Euclidean norm of the change one subject can force on the output vector, over all neighbouring datasets DDD \sim D' differing by adding or removing that subject:

Δ2=maxDDq(D)q(D)2.\Delta_2 = \max_{D \sim D'} \lVert q(D) - q(D') \rVert_2.

  • Per-cell count vectors. If the grid partitions space so each subject falls in exactly one cell, adding a subject changes one component by 11, so Δ2=1\Delta_2 = 1. If a subject can contribute to up to cc cells (overlapping buffers, multi-visit histograms), the worst-case change is a vector with cc entries of ±1\pm 1, giving Δ2=c\Delta_2 = \sqrt{c} — the square-root, not cc, which is exactly why Gaussian noise scales better than Laplace on wide histograms.
  • Spatial sums. For a per-cell sum of a real attribute, clip each subject’s contribution vector to Euclidean norm CC before aggregation; then Δ2=C\Delta_2 = C by construction. This is contribution bounding: the assumed sensitivity only holds because clipping enforces it.
  • Bounded coordinate means. A mean of coordinates over a fixed population of NN has per-axis sensitivity bounded by the coordinate range divided by NN; clip coordinates to a bounding box of side RR and the two-axis mean has Δ22R/N\Delta_2 \le \sqrt{2}\,R/N.

The classic bound and why it is loose. The textbook Gaussian mechanism sets

σΔ22ln(1.25/δ)ε.\sigma \ge \frac{\Delta_2\sqrt{2\ln(1.25/\delta)}}{\varepsilon}.

This closed form is only valid for ε1\varepsilon \le 1 and is loose everywhere: the 1.251.25 constant comes from a union-bound slack in the original proof, and the bound diverges nonsensically as ε1\varepsilon \to 1. For a heatmap release at ε=2\varepsilon = 2 it can over-noise by 30–50%, wiping out spatial structure the analysts needed.

The analytic Gaussian mechanism (Balle & Wang, 2018). Instead of a slack-laden closed form, solve directly for the smallest σ\sigma whose exact privacy profile meets δ\delta. The mechanism is (ε,δ)(\varepsilon, \delta)-DP if and only if

δΦ ⁣(Δ22σεσΔ2)eεΦ ⁣(Δ22σεσΔ2),\delta \ge \Phi\!\left(\frac{\Delta_2}{2\sigma} - \frac{\varepsilon\sigma}{\Delta_2}\right) - e^{\varepsilon}\,\Phi\!\left(-\frac{\Delta_2}{2\sigma} - \frac{\varepsilon\sigma}{\Delta_2}\right),

where Φ\Phi is the standard normal CDF. The right-hand side is monotone decreasing in σ\sigma, so a bisection finds the exact minimal scale. It is valid for all ε>0\varepsilon > 0 and never over-noises.

ρ\rho-zCDP framing for composition. Zero-concentrated DP gives a cleaner accounting currency. The Gaussian mechanism with scale σ\sigma satisfies ρ\rho-zCDP with ρ=Δ22/(2σ2)\rho = \Delta_2^2 / (2\sigma^2), equivalently

σ=Δ22ρ.\sigma = \frac{\Delta_2}{\sqrt{2\rho}}.

Under zCDP, kk releases with budgets ρ1,,ρk\rho_1,\dots,\rho_k compose linearly to (iρi)\big(\sum_i \rho_i\big)-zCDP — no δ\delta bookkeeping between steps — and you convert once at the end via ε=ρ+2ρln(1/δ)\varepsilon = \rho + 2\sqrt{\rho\ln(1/\delta)}. This is the accounting model the privacy budget management guide recommends for any pipeline that issues more than a handful of Gaussian queries against the same population.

Choosing δ\delta. Treat δ\delta as the probability the mechanism silently leaks a record wholesale, so it must be cryptographically small relative to dataset size: the standard rule is δ1/N\delta \ll 1/N, often δ=N1.1\delta = N^{-1.1} or a fixed 10610^{-6} to 10910^{-9}. A common error is reusing δ=105\delta = 10^{-5} regardless of NN; for a billion-row mobility feed that is far too weak, because in expectation more than one subject can be exposed.

Reference Implementation

The function below calibrates σ\sigma with the analytic Gaussian mechanism using only the standard library plus numpy, and apply_gaussian draws the isotropic noise. The classic bound is included as a reference so the validation harness can confirm the analytic scale is genuinely tighter.

python
from __future__ import annotations

import math
from typing import Optional

import numpy as np


def _phi(x: float) -> float:
    """Standard normal CDF via the error function (stdlib only, no SciPy)."""
    return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))


def classic_gaussian_sigma(l2_sensitivity: float, epsilon: float, delta: float) -> float:
    """Textbook bound sigma >= Delta2 * sqrt(2 ln(1.25/delta)) / epsilon.

    Only valid for epsilon <= 1 and loose everywhere; kept for comparison.
    """
    return l2_sensitivity * math.sqrt(2.0 * math.log(1.25 / delta)) / epsilon


def calibrate_gaussian_sigma(
    l2_sensitivity: float,
    epsilon: float,
    delta: float,
    *,
    rtol: float = 1e-12,
) -> float:
    """Smallest sigma making the Gaussian mechanism (epsilon, delta)-DP.

    Uses the analytic Gaussian mechanism (Balle & Wang, 2018): it solves the
    exact privacy-profile equation instead of the slack-laden closed form, so
    it stays tight for epsilon > 1 and never over-noises a spatial aggregate.

    Args:
        l2_sensitivity: Delta_2, the max Euclidean change one subject can force
            on the aggregate vector. MUST already reflect contribution clipping;
            an underestimate here is a silent privacy violation, not a bug.
        epsilon: target privacy loss (> 0).
        delta: target failure probability. Choose delta << 1/N so, in
            expectation, fewer than one subject can ever be exposed.
    Returns:
        The minimal sigma; the returned value always satisfies the DP bound.
    """
    if l2_sensitivity <= 0.0:
        raise ValueError("l2_sensitivity (Delta_2) must be positive")
    if epsilon <= 0.0:
        raise ValueError("epsilon must be positive")
    if not (0.0 < delta < 1.0):
        raise ValueError("delta must lie strictly in (0, 1)")

    a = l2_sensitivity

    def delta_of_sigma(sigma: float) -> float:
        # Exact (epsilon, delta) trade-off of an isotropic Gaussian at scale sigma.
        # Monotone decreasing in sigma, so bisection is safe.
        return (
            _phi(a / (2.0 * sigma) - epsilon * sigma / a)
            - math.exp(epsilon) * _phi(-a / (2.0 * sigma) - epsilon * sigma / a)
        )

    # Bracket: grow the upper bound until it over-satisfies the delta target.
    lo, hi = 1e-12, max(1.0, a)
    while delta_of_sigma(hi) > delta:
        hi *= 2.0
    # Bisect. hi always keeps delta_of_sigma(hi) <= delta, so returning hi is safe.
    for _ in range(200):
        mid = 0.5 * (lo + hi)
        if delta_of_sigma(mid) > delta:
            lo = mid
        else:
            hi = mid
        if hi - lo <= rtol * hi:
            break
    return hi


def sigma_from_zcdp(l2_sensitivity: float, rho: float) -> float:
    """Gaussian scale for a rho-zCDP budget: sigma = Delta_2 / sqrt(2 rho)."""
    if rho <= 0.0:
        raise ValueError("rho must be positive")
    return l2_sensitivity / math.sqrt(2.0 * rho)


def apply_gaussian(
    vec: np.ndarray,
    sigma: float,
    *,
    rng: Optional[np.random.Generator] = None,
) -> np.ndarray:
    """Add isotropic N(0, sigma^2) noise to every component of an aggregate.

    Noise is drawn independently per cell; the (epsilon, delta) guarantee holds
    for the WHOLE vector because sigma was calibrated to its L2 sensitivity.
    Any downstream transform (clamping to >= 0, smoothing, rendering a heatmap)
    is post-processing and consumes no further budget.
    """
    rng = np.random.default_rng() if rng is None else rng
    return vec.astype(float) + rng.normal(0.0, sigma, size=vec.shape)

The apply_gaussian docstring names the load-bearing property: because σ\sigma was calibrated to the vector’s Δ2\Delta_2, the joint release is private, and post-processing — clamping negative counts to zero, rendering tiles — is free. Never re-noise a derived quantity thinking it needs its own budget; that only wastes utility.

Validation Checkpoint

A mis-set Δ2\Delta_2 or a σ\sigma off the tight frontier fails silently: the release still looks noised. The harness below asserts the calibrated scale actually satisfies the analytic bound, that it beats the classic bound, that the zCDP conversion is consistent, and that the empirical noise standard deviation matches σ\sigma.

python
def _validate() -> None:
    rng = np.random.default_rng(7)

    eps, delta, d2 = 1.5, 1e-6, math.sqrt(4.0)   # subject touches <= 4 grid cells

    sigma = calibrate_gaussian_sigma(d2, eps, delta)

    # 1. The analytic sigma satisfies the exact (eps, delta)-DP condition.
    a = d2
    achieved = (
        _phi(a / (2 * sigma) - eps * sigma / a)
        - math.exp(eps) * _phi(-a / (2 * sigma) - eps * sigma / a)
    )
    assert achieved <= delta + 1e-12, "calibrated sigma violates the DP bound"

    # 2. The analytic scale is strictly tighter than the classic closed form.
    assert sigma < classic_gaussian_sigma(d2, eps, delta), "analytic must beat classic"

    # 3. zCDP round-trip: rho = Delta2^2 / (2 sigma^2) reproduces the same sigma.
    rho = d2 ** 2 / (2 * sigma ** 2)
    assert math.isclose(sigma_from_zcdp(d2, rho), sigma, rel_tol=1e-9)

    # 4. Empirical noise std matches sigma on a large per-cell count vector.
    counts = rng.integers(0, 500, size=200_000).astype(float)
    noised = apply_gaussian(counts, sigma, rng=rng)
    emp_std = float(np.std(noised - counts))
    assert abs(emp_std - sigma) / sigma < 0.02, f"empirical std {emp_std} != {sigma}"

    print(f"OK: sigma={sigma:.4f}  rho={rho:.5f}  empirical_std={emp_std:.4f}")


if __name__ == "__main__":
    _validate()
From L2 sensitivity of a spatial aggregate to a calibrated Gaussian release Left-to-right flow. Two stacked boxes on the left hold neighbouring aggregate vectors: q(D), a per-cell count histogram, and q(D prime), the same histogram after adding one subject who touches up to c cells. Both feed a central L2 sensitivity node that computes Delta-2 as the Euclidean norm of q(D) minus q(D prime), which equals the square root of c. An arrow carries Delta-2 into the analytic Gaussian calibration node, which also receives an epsilon and delta input from above and solves the Balle and Wang privacy-profile equation for the smallest sigma. A final arrow reaches the release node, which outputs q(D) plus isotropic Gaussian noise of variance sigma squared added independently to every cell. A caption notes that sigma equals Delta-2 over the square root of two rho under zCDP. L2 sensitivity drives Gaussian noise calibration for a spatial aggregate Aggregate q(D) 4 7 2 5 per-cell counts Neighbour q(D′) 5 8 2 6 +1 subject, up to c cells L2 sensitivity Δ₂ = ‖q(D) − q(D′)‖₂ = √c inputs (ε, δ), δ ≪ 1/N Analytic Gaussian calibration (Balle–Wang) solve for smallest σ tight for ε > 1 Noised release q(D) + N(0, σ²I) per-cell noise Under ρ-zCDP the same scale is σ = Δ₂ / √(2ρ), and budgets add linearly across releases.

Incident Response and Edge Cases

Gaussian calibration errors do not throw — they quietly ship a release that is either non-private or unusable. The four that surface most on live spatial pipelines:

  • Underestimated Δ2\Delta_2 from uncapped contributions. A subject who appears in more grid cells than assumed — repeated visits, an oversized buffer — makes the true Δ2\Delta_2 exceed the value passed to calibrate_gaussian_sigma, so the actual privacy loss is larger than ε\varepsilon. Detection: audit the maximum number of cells (or the maximum contribution norm) any single subject reaches against the assumed cc or clip bound CC. Remediation: enforce contribution bounding before aggregation — cap cells per subject at cc or clip the per-subject vector to norm CC — and recalibrate. The assumed sensitivity is only real if clipping makes it real.
  • Using the classic bound at ε>1\varepsilon > 1. The closed form σΔ22ln(1.25/δ)/ε\sigma \ge \Delta_2\sqrt{2\ln(1.25/\delta)}/\varepsilon is not valid above ε=1\varepsilon = 1 and either over-noises or, worse, is misapplied as if valid. Detection: flag any release where ε>1\varepsilon > 1 still routes through the closed form. Remediation: switch to calibrate_gaussian_sigma; the analytic scale is provably tight for all ε\varepsilon.
  • δ\delta too large for the population. A fixed δ=105\delta = 10^{-5} on a dataset of tens of millions of subjects violates δ1/N\delta \ll 1/N, so the residual failure probability covers more than one real record. Detection: compare δ\delta against 1/N1/N at release time. Remediation: set δ=N1.1\delta = N^{-1.1} or a hard floor like 10710^{-7}, then recalibrate — σ\sigma grows only logarithmically in 1/δ1/\delta, so tightening δ\delta costs little utility.
  • Composition tracked in (ε,δ)(\varepsilon,\delta) instead of ρ\rho. Summing (ε,δ)(\varepsilon,\delta) pairs naively across many heatmap tiles overcounts the true loss and exhausts the budget prematurely. Detection: a budget ledger that adds ε\varepsilon per query without an accountant. Remediation: convert each Gaussian release to ρ=Δ22/(2σ2)\rho = \Delta_2^2/(2\sigma^2), sum ρ\rho linearly, and convert once — the discipline the privacy budget management guide enforces.

Frequently Asked Questions

When should I use the Gaussian mechanism instead of Laplace for a spatial release?

Reach for Gaussian when the query is a high-dimensional vector — a per-cell count histogram, a multi-cell spatial sum — because it pays for L2 (Euclidean) distance, and a subject touching c cells gives sensitivity of only the square root of c, versus c for Laplace under L1. Gaussian also composes cleanly under zCDP. Prefer Laplace for scalar or low-dimensional queries and for perturbing a single location, where the planar Laplace mechanism gives a metric guarantee Gaussian does not.

Why is the analytic Gaussian mechanism better than the classic sigma formula?

The classic bound is a closed form derived with union-bound slack; it is only valid for epsilon at most 1 and over-noises everywhere else, often by 30 to 50 percent on a heatmap at epsilon of 2. The analytic Gaussian mechanism solves the exact privacy-profile equation for the smallest sigma that meets delta, is valid for every epsilon, and never wastes utility. It is a short numerical bisection, so there is no reason to keep using the loose form in production.

How do I choose delta for a Gaussian spatial aggregate?

Treat delta as the probability the guarantee silently fails and set it well below one over the number of subjects, so in expectation fewer than one record can be exposed. A billion-row mobility feed needs delta near ten to the minus nine, not the ten to the minus five that suffices for a small cohort. Because sigma grows only with the square root of the log of one over delta, tightening delta costs very little noise, so err small.

Do I need to add noise again to a heatmap rendered from a noised aggregate?

No. Once sigma is calibrated to the vector's L2 sensitivity and the noise is added, every downstream transform — clamping negative counts to zero, smoothing, colour-mapping into tiles — is post-processing and consumes no additional budget. Re-noising a derived product only destroys utility. Track the single Gaussian release in the accountant and leave the rendering alone.

Up: Spatial Noise Mechanisms · Differential Privacy for Geospatial Data