Remapping Attacks Against Geo-Indistinguishability

Geo-indistinguishability produces reports that can land in the sea, inside a mountain, or two kilometres past the city boundary — so almost every deployment adds a remapping step that moves the report somewhere plausible. That step is post-processing, which cannot weaken the formal guarantee, and yet a badly chosen remap reliably does weaken the practical one. The reason is that remapping is a deterministic function the adversary also knows, and inverting it narrows the set of true locations consistent with a report. This page explains the attack and the two remaps that survive it, under geo-indistinguishability in Differential Privacy for Geospatial Data.

Parameter Configuration and Calibration

  • region — the set the report must land in. A service area, a landmass, a road network. The tighter the region relative to the noise, the more often the remap fires and the more it matters. Measure the firing rate before choosing a remap: below a few percent almost anything is safe; above 20% the remap is the mechanism.
  • remap_rule — how an out-of-region point is moved. Three families, in increasing order of safety: nearest-point projection (dangerous), resample-until-inside (safe but costly), and optimal remapping toward a prior (safe and useful). Which one you pick is the entire subject of this page.
  • prior — the reference distribution for optimal remapping. Population density, or the historical distribution of reports. A remap toward a uniform prior throws away utility; a remap toward a very sharp prior pulls reports onto a handful of points and destroys the guarantee’s practical value even though the formal one survives.
  • max_resample_attempts — the bound on the safe-but-costly option. Resampling has no upper bound in principle. Cap the attempts and fall back to a documented rule, because an unbounded loop on a handset is a battery incident.
Remap rule Formal guarantee Practical effect Verdict
Nearest point on the boundary preserved reports pile onto the boundary; inversion is trivial do not use
Snap to nearest road/POI preserved many-to-one onto a sparse set; strong inversion avoid
Resample until inside preserved conditional distribution, no pile-up safe
Optimal remap toward a prior preserved improves expected error; well-studied best
How often the remap fires, and how little variety it produces A grouped bar chart across four privacy radii. The share of draws falling outside the region and therefore remapped rises from eight percent at a 200 metre radius to seventy-eight percent at 1,600 metres. The naive boundary projection maps all of them onto the same sixteen vertices regardless, so at the larger radii most reports are one of sixteen values. How often the remap fires, and how little variety it produces 4 km square service region, boundary projection with 16 vertices 0 25 50 75 % / count 8 16 r = 200 m 24 16 r = 400 m 52 16 r = 800 m 78 16 r = 1600 m % of draws remapped distinct output points (naive)
At r = 1.6 km, nearly four in five reports land on one of sixteen points. The remap, not the mechanism, is deciding where reports go.

Reference Implementation

python
from __future__ import annotations

import math
import random
from dataclasses import dataclass
from typing import Callable, Sequence


@dataclass(frozen=True)
class RemapReport:
    x: float
    y: float
    attempts: int
    remapped: bool


def naive_boundary_remap(
    x: float, y: float, *, inside: Callable[[float, float], bool],
    boundary: Sequence[tuple[float, float]],
) -> RemapReport:
    """The dangerous rule: project an outside point to the nearest boundary vertex.

    Included so its failure can be measured, not because it should be deployed. It
    is a many-to-one map onto a small set, so a report ON the boundary tells the
    adversary that the true location was somewhere in the (large) region that maps
    to that vertex — which is far more informative than the mechanism intended.
    """
    if inside(x, y):
        return RemapReport(x, y, 0, False)
    bx, by = min(boundary, key=lambda p: math.hypot(p[0] - x, p[1] - y))
    return RemapReport(bx, by, 1, True)


def resample_remap(
    sample: Callable[[], tuple[float, float]],
    *,
    inside: Callable[[float, float], bool],
    max_attempts: int = 64,
) -> RemapReport:
    """The safe rule: draw again until the report lands inside the region.

    This yields the mechanism's own distribution CONDITIONED on the region, which
    remains a valid geo-indistinguishable release. Cost is unbounded in principle,
    so the attempt cap matters; on exhaustion the caller must fall back to a
    documented, region-wide rule rather than to the last (outside) draw.
    """
    for attempt in range(1, max_attempts + 1):
        x, y = sample()
        if inside(x, y):
            return RemapReport(x, y, attempt, attempt > 1)
    raise RuntimeError(
        "resampling exhausted: the privacy radius is large relative to the region — "
        "reduce the radius or widen the region rather than falling back to a projection"
    )


def optimal_remap(
    x: float, y: float,
    *,
    candidates: Sequence[tuple[float, float]],
    prior: Sequence[float],
    epsilon: float,
) -> RemapReport:
    """Move the report to the candidate minimising expected error under a prior.

    The remap is a function of the REPORT only — never of the true location — which
    is what keeps it post-processing. Weighting by the prior's posterior for each
    candidate given the observed report is the standard optimal-remap construction.
    """
    if len(candidates) != len(prior):
        raise ValueError("prior must be aligned with candidates")
    best, best_cost = None, float("inf")
    for cx, cy in candidates:
        cost = 0.0
        for (px, py), w in zip(candidates, prior):
            d_report = math.hypot(px - x, py - y)
            posterior = w * math.exp(-epsilon * d_report)
            cost += posterior * math.hypot(px - cx, py - cy)
        if cost < best_cost:
            best, best_cost = (cx, cy), cost
    assert best is not None
    return RemapReport(best[0], best[1], 1, best != (x, y))

Validation Checkpoint

python
def _validate() -> None:
    rng = random.Random(17)

    # A square service region, 4 km on a side, centred on the origin.
    def inside(x: float, y: float) -> bool:
        return abs(x) <= 2_000 and abs(y) <= 2_000

    boundary = [(2_000.0, y) for y in range(-2_000, 2_001, 250)]

    def sample_at(cx: float, cy: float, radius_m: float):
        def draw() -> tuple[float, float]:
            theta = rng.random() * 2 * math.pi
            r = -radius_m / math.log(2) * math.log(rng.random())
            return cx + r * math.cos(theta), cy + r * math.sin(theta)
        return draw

    # 1. Resampling always returns a point inside the region.
    draw = sample_at(1_900.0, 0.0, 400.0)
    reports = [resample_remap(draw, inside=inside) for _ in range(500)]
    assert all(inside(r.x, r.y) for r in reports)

    # 2. Resampling must NOT concentrate mass on the boundary.
    on_edge = sum(1 for r in reports if abs(abs(r.x) - 2_000) < 1.0)
    assert on_edge == 0, "the conditional distribution has no atom at the edge"

    # 3. The naive projection DOES concentrate — this is the attack surface.
    naive = [naive_boundary_remap(*draw(), inside=inside, boundary=boundary)
             for _ in range(500)]
    piled = sum(1 for r in naive if r.remapped)
    distinct = len({(round(r.x), round(r.y)) for r in naive if r.remapped})
    assert piled > 100 and distinct <= len(boundary), (piled, distinct)
    # A report at a boundary vertex is consistent with a large half-plane of truths,
    # so an adversary seeing one learns "outside, in this direction" with certainty.

    # 4. Optimal remapping must move reports toward the prior's mass, not away.
    candidates = [(-1_500.0, 0.0), (0.0, 0.0), (1_500.0, 0.0)]
    prior = [0.1, 0.8, 0.1]
    moved = optimal_remap(1_400.0, 0.0, candidates=candidates, prior=prior,
                          epsilon=math.log(2) / 800.0)
    assert moved.x <= 1_500.0

    # 5. Resampling must fail loudly when the region cannot contain the noise.
    tiny = lambda x, y: abs(x) < 5 and abs(y) < 5
    try:
        resample_remap(sample_at(0.0, 0.0, 5_000.0), inside=tiny, max_attempts=32)
    except RuntimeError as exc:
        assert "resampling exhausted" in str(exc)
    else:
        raise AssertionError("an impossible region must raise, not silently project")

    print("remapping: all assertions passed")


_validate()
Four remaps that all preserve the formal guarantee A four-row comparison of remapping rules. All four preserve the formal epsilon guarantee, because remapping is post-processing. They differ sharply in output variety and inversion risk: boundary projection and POI snapping map many inputs onto a few outputs and are highly invertible, while resampling produces the mechanism's own conditional distribution and adds no inversion risk. Four remaps that all preserve the formal guarantee and differ enormously in what an adversary can invert formal ε output variety inversion risk Nearest boundary point preserved a few vertices high Snap to nearest POI preserved the POI set high Resample until inside preserved full, conditioned none added Optimal remap toward a prior preserved candidate set low
“Post-processing cannot weaken DP” is true and insufficient. The theorem bounds the likelihood ratio; it says nothing about a many-to-one map collapsing outputs onto a handful of values.

Assertion 3 is the attack, quantified. Under the naive projection, a substantial fraction of reports land on a small set of boundary vertices, and each of those reports tells the adversary that the true location was outside the region in a particular direction — information the unremapped mechanism would never have revealed.

Incident Response and Edge Cases

  • A large fraction of reports sit exactly on the region boundary. The naive projection is in use. Replace it with resampling; the change is small and the improvement is immediate.
  • Reports cluster on a handful of POIs. Someone added a “snap to nearest landmark” step for usability. It is a many-to-one map onto a sparse set, which is the same failure as boundary projection with a friendlier name. If the product needs a landmark name, derive it for display only and never store or transmit the snapped coordinate as the report.
  • Resampling exhausts on rural devices. The privacy radius is comparable to the region size. Either widen the region (often the service area was drawn too tightly) or reduce the radius and compensate with a temporal control; falling back to projection re-introduces exactly the attack.
  • The prior used for optimal remapping is stale. A prior derived from historical reports drifts, and a mismatched prior increases expected error without breaking the guarantee. Refresh it on a schedule, and remember the prior is derived from data — publishing it is a release.
  • The remap depends on the true location. Then it is not post-processing and the guarantee is void. Audit the code path: the remap function must take the report and public parameters only, with no access to the input coordinate.
What resampling costs when the region is tight A rising curve of the expected number of draws needed until one lands inside the region, against the share of draws that fall outside. At five percent rejection the cost is barely above one draw; at fifty percent it is two; and it diverges as the rejection rate approaches one, which is the regime where resampling must fail loudly rather than loop. What resampling costs when the region is tight expected draws until a sample lands inside 20 40 60 80 0 5 10 % of draws falling outside the region expected draws expected draws per report
Resampling is cheap while rejection is rare and unbounded when it is not — hence the attempt cap, and a hard failure rather than a fallback to projection.

Frequently Asked Questions

If post-processing cannot weaken DP, how can a remap be harmful?

The formal ε-guarantee does survive — the bound on the likelihood ratio between two true locations is unchanged. What changes is the adversary's practical posterior: a many-to-one remap makes many reports identical, and observing that value reveals membership of the large set that maps to it. The theorem and the operational risk are answering different questions.

Is resampling biased?

It produces the mechanism's distribution conditioned on the region, which is exactly what you want and is still geo-indistinguishable. It is "biased" only relative to the unconditioned mechanism, which was producing unusable reports outside the region.

Which prior should optimal remapping use?

Population density or the historical report distribution, at a resolution coarse enough that the prior is not itself a sensitive artefact. Avoid very sharp priors: they pull reports onto a few points, which reproduces the many-to-one problem the remap was supposed to avoid.

Should the remap run on the device or the server?

Either is sound, since the remap sees only the report. On-device keeps the server from ever seeing the out-of-region draw, which is tidier operationally. Server-side lets the prior be updated without shipping a client release. Choose on operational grounds, and document which one, because it affects what a network observer sees.

Up one level: Geo-Indistinguishability · Section: Differential Privacy for Geospatial Data.