Adding Laplace Noise to Census-Tract Case Counts

Releasing exact case counts per census tract is disclosive: a small cell effectively announces individuals, and several such tables let an attacker reconstruct records. This guide, part of Differential Privacy for Spatial Aggregates, shows the focused mechanics of perturbing those counts with Laplace noise of scale 1/ε1/\varepsilon so each release carries a provable guarantee and a reproducible provenance trail.

Problem Context & Constraints

The naive habit is to publish the count column as-is and rely on a suppression rule to blank out cells below some threshold. That approach fails on its own terms for two reasons. First, suppression leaks through arithmetic: if a county total is public and every tract but one is shown, the suppressed tract is recoverable by subtraction — the “complementary disclosure” problem. Second, even a fully shown table becomes a set of linear equations once you hold two overlapping tabulations of the same people, and solving that system reconstructs individuals. The Laplace mechanism sidesteps both by injecting calibrated randomness into every cell, so no exact equation survives and the residual risk is bounded by a number you set and record.

Three constraints shape the recipe and separate it from generic “add some noise” advice:

  • The noise magnitude is not a tuning knob you eyeball — it is fixed by the privacy math. For a count query, one person changes the value by at most one, so the global sensitivity is Δf=1\Delta f = 1, and the Laplace scale is pinned to Δf/ε\Delta f / \varepsilon. Choosing a smaller magnitude to make the map look cleaner silently voids the guarantee.
  • The draw must be reproducible for audit yet unpredictable to an attacker. A regulator has to be able to re-run the release and obtain the identical private table, which means the pseudo-random draw is seeded and the seed is stored — but only in the access-controlled provenance record, never in the public artifact.
  • The output is a real number and must be repaired before release. Laplace draws produce negatives and fractions; a case count of 1.7-1.7 is nonsensical to publish. Non-negativity and integer rounding are applied as post-processing, which the post-processing theorem makes free of additional privacy cost.

The central tension of this recipe is that the noise magnitude is absolute, so the same draw that is invisible against a tract of 400 cases can overwhelm a tract of 4:

Same Noise, Different Signal Two panels compare the effect of identical Laplace noise. The left panel shows a small tract with a true count of four: the Laplace distribution centered on four is wide relative to the value, its left tail crosses zero, so the relative error is large and clamping introduces bias. The right panel shows a large tract with a true count of four hundred: the same-width noise distribution is negligible against the value, so the signal survives and relative error is tiny. Laplace noise is absolute — its scale is 1/ε regardless of the true count Small tract 0 true = 4 relative error ≈ 35% left tail < 0 → clamped up (bias) Large tract 0 true = 400 relative error ≈ 0.35% signal survives The identical noise band swamps a count of 4 but is negligible against 400 — so anchor ε to the smallest cell

Prerequisites

Pin the stack so seeded draws and hashes reproduce exactly for a reviewer:

  • python 3.11
  • numpy 1.26.4 — the Laplace generator and vectorized post-processing
  • geopandas 0.14.4 (pulls shapely 2.0.x, pyproj 3.6.x) — the tract layer and its stable GEOIDs

Input state assumed by the code below:

  • A GeoDataFrame of tracts with an integer case_count column and a stable, sorted geoid key, so a re-run hashes identically. The geometry should already be reprojected and validated per coordinate reference systems for public health; this recipe touches only the count column, but the frozen GEOID set is what the provenance hash binds to.
  • A privacy-loss budget ε\varepsilon decided by policy in advance. This recipe spends the full ε\varepsilon on one single-level count release; splitting a budget across levels is covered in allocating a privacy budget across nested geographies.

Step-by-Step Solution

The Laplace scale is set directly by the sensitivity and the budget:

b=Δfε=1ε,b = \frac{\Delta f}{\varepsilon} = \frac{1}{\varepsilon},

and the resulting per-cell noise has standard deviation 2b=2/ε\sqrt{2}\,b = \sqrt{2}/\varepsilon, which is the number to quote when you tell downstream users how much a released value may wobble. The routine below draws from a seeded generator, records ε\varepsilon and the seed in a provenance block, clamps negatives, optionally rounds to integers, and returns a utility summary so the tradeoff is visible at release time.

# Focused Laplace mechanism for a single-level tract count release.
# python 3.11
# numpy==1.26.4, geopandas==0.14.4
import hashlib
import json
import logging
import numpy as np
import geopandas as gpd

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dp.tract_laplace")

def private_tract_counts(
    gdf: gpd.GeoDataFrame,
    epsilon: float,
    *,
    count_col: str = "case_count",
    seed: int = 20260713,
    round_int: bool = True,
) -> gpd.GeoDataFrame:
    """Add Laplace(0, 1/epsilon) noise to tract counts (sensitivity = 1).

    Returns a copy with 'dp_count' plus a provenance dict in .attrs.
    """
    if epsilon <= 0:
        raise ValueError("epsilon must be > 0; it is the privacy-loss budget.")

    out = gdf.copy()
    true_counts = out[count_col].to_numpy(dtype=float)
    n = true_counts.size

    sensitivity = 1.0                      # add/remove-one count query
    scale_b = sensitivity / epsilon        # b = Δf / ε

    # Seeded draw: reproducible for audit, secret to attackers pre-release.
    rng = np.random.default_rng(seed)
    noise = rng.laplace(loc=0.0, scale=scale_b, size=n)
    noisy = true_counts + noise

    # ---- Post-processing (free under DP) ----
    neg_fraction = float(np.mean(noisy < 0))   # diagnostic BEFORE clamping
    dp = np.clip(noisy, a_min=0.0, a_max=None)
    if round_int:
        dp = np.rint(dp).astype(int)
    out["dp_count"] = dp

    provenance = {
        "mechanism": "laplace",
        "epsilon": epsilon,
        "sensitivity": sensitivity,
        "scale_b": scale_b,
        "noise_sd_theoretical": float(np.sqrt(2) * scale_b),
        "seed": seed,
        "n_tracts": n,
        "pre_clamp_negative_fraction": neg_fraction,
        "post_processing": ["clip_nonneg"] + (["round_int"] if round_int else []),
        "input_sha256": hashlib.sha256(
            json.dumps(true_counts.astype(int).tolist(), sort_keys=True).encode()
        ).hexdigest(),
    }
    out.attrs["dp_provenance"] = provenance
    log.info("release: %s", json.dumps({k: provenance[k] for k in
             ("epsilon", "scale_b", "noise_sd_theoretical", "seed",
              "pre_clamp_negative_fraction")}))
    return out

Utility degrades smoothly as ε\varepsilon shrinks. Because the scale is 1/ε1/\varepsilon, halving the budget doubles the noise. A quick sweep makes the tradeoff explicit before you commit to a value:

def utility_vs_epsilon(gdf, epsilons, count_col="case_count"):
    """Mean absolute error of dp_count vs true count across a grid of epsilon."""
    truth = gdf[count_col].to_numpy(dtype=float)
    for eps in epsilons:
        rel = private_tract_counts(gdf, epsilon=eps, count_col=count_col)
        mae = float(np.mean(np.abs(rel["dp_count"].to_numpy(float) - truth)))
        log.info("epsilon=%.2f  scale_b=%.2f  mean_abs_error=%.2f", eps, 1.0 / eps, mae)

Validation & Edge Cases

A private release still has to be checked — not against the true values a user will never see, but against the mechanism’s own promises. Three concrete failure modes recur.

1. The empirical noise scale does not match the theoretical one. If a refactor accidentally passed a variance where a scale was expected, or reused a generator statefully across calls, the injected noise no longer matches 1/ε1/\varepsilon and the guarantee is wrong. Validate by drawing the noise alone and comparing its sample standard deviation to 2/ε\sqrt{2}/\varepsilon:

def check_noise_scale(epsilon, n=200_000, seed=20260713, tol=0.03):
    rng = np.random.default_rng(seed)
    noise = rng.laplace(loc=0.0, scale=1.0 / epsilon, size=n)
    empirical_sd = float(noise.std())
    theoretical_sd = float(np.sqrt(2) / epsilon)
    ok = abs(empirical_sd - theoretical_sd) / theoretical_sd < tol
    log.info("noise-scale check: empirical=%.3f theoretical=%.3f ok=%s",
             empirical_sd, theoretical_sd, ok)
    return ok
INFO noise-scale check: empirical=1.412 theoretical=1.414 ok=True

2. Noise swamps the smallest counts. When a tract’s true count is comparable to 2/ε\sqrt{2}/\varepsilon, the released value is mostly noise. Quantify this directly so noise-dominated cells are flagged rather than published as if firm:

def flag_noise_dominated(gdf, epsilon, count_col="case_count", ratio=1.0):
    noise_sd = np.sqrt(2) / epsilon
    small = gdf[count_col].to_numpy(float) <= ratio * noise_sd
    log.warning("noise-dominated tracts: %d of %d (count <= %.2f)",
                int(small.sum()), len(gdf), ratio * noise_sd)
    return small

At ε=1\varepsilon = 1 the noise SD is about 1.41, so every tract with four or fewer cases carries a relative error near or above a third. The fix is not a bigger ε\varepsilon — that stops protecting anyone — but coarser geography, which is why rare-disease releases route through adaptive areal aggregation for rare disease counts before this mechanism runs.

3. Clamping bias goes unreported. Clamping negatives to zero is mandatory but not neutral: it folds the below-zero tail of the Laplace back up, adding a small positive bias to the smallest cells. The pre_clamp_negative_fraction in the provenance block is the diagnostic — a high value means many cells were pushed below zero and the released small-cell counts are biased upward. Surface that fraction in the release notes rather than letting a downstream analyst assume the private counts are unbiased.

Compliance Notes

Three artifacts make this release defensible under review, and each maps to a field the code already emits:

  • Epsilon and sensitivity, stated and justified. Log the ε\varepsilon spent and the sensitivity assumed (Δf=1\Delta f = 1, from the add/remove-one neighbor definition). This is the binding privacy claim; it belongs in a version-controlled release record, not only in code.
  • The seed, held privately. Store the seed in the access-controlled provenance record so a regulator can reproduce the exact private table, but keep it out of the public artifact — a published seed plus the mechanism lets an attacker back out the noise. This mirrors the payload-hash discipline that also underpins enforcing spatial k-anonymity on case point releases: prove what was done without exposing what must stay secret.
  • The input hash. The SHA-256 of the true counts ties the release to a specific dataset vintage and closes the audit loop, so a re-run on the same input and seed reproduces byte-identical private outputs.