Randomized Response for Grid-Cell Reporting

Randomized response is the oldest privacy mechanism still in production use, and it is the right starting point for on-device location reporting because it is small enough to audit line by line. The client reports its true grid cell with a probability derived from ε\varepsilon, and otherwise reports a uniformly random cell from the domain; the server inverts that distribution to recover frequencies. This page implements the generalised (multi-valued) form for grid cells, under local differential privacy for mobile clients in Differential Privacy for Geospatial Data.

Parameter Configuration and Calibration

  • domain — the number of reportable cells dd. The dominating parameter. Truth-telling probability is p=eε/(eε+d1)p = e^{\varepsilon}/(e^{\varepsilon} + d - 1), so at ε=2\varepsilon = 2 a ten-cell domain keeps the truth 45% of the time while a thousand-cell domain keeps it 0.7% of the time. Direct randomised response is only sensible for small domains — districts, corridors, road classes — and unary encoding or local hashing should be used past a few dozen cells.
  • epsilon — the per-report budget spent by the client. With memoisation this is spent once per distinct cell, not once per report. Values between 1 and 4 are typical for consumer telemetry; below 1 the estimator needs enormous populations, above 4 the truth-telling probability is high enough that a single report is a meaningful disclosure for a small domain.
  • cap_distinct_cells — the number of distinct cells one client may report per epoch. This is what turns a per-report ε\varepsilon into a per-client-per-epoch guarantee of ε×cap\varepsilon \times \text{cap}. It is enforced on the device.
  • min_reporters — the per-cell suppression floor. Estimator variance is fixed by ε\varepsilon and dd, so a cell with few reporters has an error bar wider than any plausible count. Suppress rather than publish.
Domain size dd pp at ε = 1 pp at ε = 2 pp at ε = 4 Verdict
8 (city districts) 28% 51% 89% direct RR works
64 (neighbourhoods) 4.1% 10.5% 46% borderline; check variance
2,000 (res-8 metro grid) 0.14% 0.37% 2.7% use unary or hashing
Truth-telling probability collapses with the domain Three falling curves of the truth-telling probability against domain size on a logarithmic axis. At epsilon four and eight cells the client reports honestly about eighty-nine percent of the time; at a thousand cells the same epsilon yields under three percent. Every curve falls roughly as one over the domain size, which is why direct randomised response is only viable for small domains. Truth-telling probability collapses with the domain p = e^ε / (e^ε + d − 1) 10 100 1000 0 50 100 domain size d (cells) % of reports that are truthful ε = 1.0 ε = 2.0 ε = 4.0
Quote this alongside ε in any review. The same ε over 8 districts and over 1,000 cells describes completely different disclosures.

Reference Implementation

python
from __future__ import annotations

import math
import secrets
from dataclasses import dataclass

import numpy as np


@dataclass(frozen=True)
class RRParams:
    """Generalised randomised response over a grid-cell domain."""

    epsilon: float
    domain: int

    def __post_init__(self) -> None:
        if self.domain < 2:
            raise ValueError("randomised response needs at least two cells")
        if self.epsilon <= 0:
            raise ValueError("epsilon must be positive")

    @property
    def p_truth(self) -> float:
        """Probability the client reports its real cell."""
        e = math.exp(self.epsilon)
        return e / (e + self.domain - 1)

    @property
    def p_other(self) -> float:
        """Probability of reporting any one specific other cell."""
        return (1.0 - self.p_truth) / (self.domain - 1)

    def estimator_variance(self, n: int) -> float:
        """Variance of one cell's estimated count over n reporters."""
        p, q = self.p_truth, self.p_other
        return n * q * (1.0 - q) / ((p - q) ** 2)


def report_cell(true_cell: int, params: RRParams) -> int:
    """Randomise one grid-cell report on the device.

    `secrets` is used rather than `random` because the guarantee rests entirely on
    the unpredictability of this draw: an adversary who can reproduce the RNG state
    recovers the true cell exactly, and there is no partial degradation.
    """
    if not 0 <= true_cell < params.domain:
        raise ValueError("cell outside the declared domain")
    if secrets.randbelow(10 ** 9) < int(params.p_truth * 10 ** 9):
        return true_cell
    # Pick uniformly among the OTHER cells — including the true cell here would
    # skew p_truth upward and silently break the claimed epsilon.
    other = secrets.randbelow(params.domain - 1)
    return other if other < true_cell else other + 1


def estimate_counts(reports: np.ndarray, params: RRParams) -> np.ndarray:
    """Invert the randomisation into unbiased per-cell counts.

    Raw report tallies are biased upward for every cell, because each cell collects
    a share of everyone else's random reports. Publishing tallies without this
    inversion is the classic implementation error: it produces a map on which every
    cell looks moderately busy.
    """
    n = int(reports.size)
    tally = np.bincount(reports, minlength=params.domain).astype(float)
    p, q = params.p_truth, params.p_other
    return (tally - n * q) / (p - q)


def confidence_interval(params: RRParams, n: int, z: float = 1.96) -> float:
    """Half-width of the interval around each estimated count."""
    return z * math.sqrt(params.estimator_variance(n))

Validation Checkpoint

python
def _validate() -> None:
    rng = np.random.default_rng(23)
    params = RRParams(epsilon=2.0, domain=16)

    # A known population: 40% in cell 3, 25% in cell 9, the rest spread out.
    truth = np.concatenate([
        np.full(40_000, 3), np.full(25_000, 9),
        rng.integers(0, 16, size=35_000),
    ])
    reports = np.fromiter((report_cell(int(c), params) for c in truth),
                          dtype=int, count=truth.size)
    estimates = estimate_counts(reports, params)
    true_counts = np.bincount(truth, minlength=16).astype(float)
    half_width = confidence_interval(params, truth.size)

    # 1. The estimator is unbiased: every cell lands inside its interval.
    assert np.all(np.abs(estimates - true_counts) < half_width * 1.5), (
        np.max(np.abs(estimates - true_counts)), half_width
    )

    # 2. Raw tallies are NOT unbiased — this is the bug the inversion prevents.
    raw = np.bincount(reports, minlength=16).astype(float)
    empty_cells = np.where(true_counts < 100)[0]
    if empty_cells.size:
        assert raw[empty_cells].mean() > 500, "raw tallies must show the noise floor"
        assert abs(estimates[empty_cells].mean()) < half_width

    # 3. Truth-telling probability must match the closed form.
    matches = float(np.mean(reports == truth))
    assert abs(matches - params.p_truth) < 0.01, (matches, params.p_truth)

    # 4. Widening the domain at fixed epsilon must increase variance.
    wide = RRParams(epsilon=2.0, domain=256)
    assert wide.estimator_variance(100_000) > params.estimator_variance(100_000)

    # 5. The randomiser must never be able to return an out-of-domain cell.
    assert all(0 <= report_cell(0, params) < params.domain for _ in range(2_000))

    print("randomised response: all assertions passed")


_validate()
Raw tallies say every cell is busy; the inversion does not A grouped bar chart of six cells showing the true count, the raw tally of reports naming that cell, and the estimate after inverting the randomisation. The raw tallies are inflated for every cell by the share of other clients' random reports, so quiet cells appear substantially busy. After inversion the estimates track the true counts closely, including for the quiet cells. Raw tallies say every cell is busy; the inversion does not d = 16, ε = 2, 60,000 reports 0 10000 20000 30000 reports / estimated count 24000 10599 27751 cell 0 15000 7629 17343 cell 1 1500 3174 1732 cell 2 1500 3174 1732 cell 3 1500 3174 1732 cell 4 1500 3174 1732 cell 5 true count raw tally after inversion
Publishing raw tallies is the classic local-DP defect: it produces a map on which everywhere looks moderately busy, and it is invisible without the true counts to compare.

Assertion 2 is worth keeping permanently. It asserts that genuinely empty cells estimate to approximately zero after inversion while their raw tallies are large — the difference between a correct implementation and one that publishes the noise floor as activity.

Incident Response and Edge Cases

  • A cell’s estimate is strongly negative. Normal for an empty cell: the estimator is unbiased, so it undershoots half the time. Report it with its interval or suppress it; clamping to zero reintroduces the upward bias in exactly the sparse areas that matter most.
  • The domain changed between client versions. Estimates from the two versions cannot be pooled, because pp and qq differ. Transmit a domain version with every report, aggregate per version, and only combine after re-weighting by each version’s reporter count.
  • A client reports a cell outside the domain. Either the client is stale or it is hostile. Drop the report and count the event; a rising rate of out-of-domain reports is the cheapest available signal of a tampered client build.
  • Estimates look plausible but the totals do not add up. Check whether the “other” draw includes the true cell. Including it inflates the effective pp and makes both the guarantee and the inversion wrong — subtly, since the estimates still look reasonable.
  • A small domain makes a single report too informative. With eight districts at ε=4\varepsilon = 4, a report is correct 89% of the time. That is a legitimate parameter choice only if the population per district is large and the reporting cadence is capped; otherwise reduce ε\varepsilon or enlarge the domain.
The distinct-value cap is the per-epoch guarantee A grouped bar chart across five caps on the number of distinct cells a client may report per epoch. The per-epoch epsilon grows linearly with the cap, from one to thirty. Coverage of a day's movement grows quickly and then saturates, so a cap of five captures most of the day's cells at a fifth of the epsilon a cap of thirty would cost. The distinct-value cap is the per-epoch guarantee ε = 1.0 per distinct cell, one-day epoch 0 50 100 ε / % 1 22 cap = 1 3 53 cap = 3 5 71 cap = 5 10 92 cap = 10 30 100 cap = 30 per-epoch ε % of a day's cells reported
A cap of 30 is not a privacy parameter — it is a per-day ε of 30. The knee around 5 keeps the guarantee statable while retaining most of the coverage.

When documenting this mechanism for a review, quote three numbers together rather than epsilon alone: the domain size, the truth-telling probability it implies, and the per-epoch cap on distinct values. Those three determine what a report actually discloses, and a reviewer given only epsilon has no way to tell a well-configured deployment from a badly configured one — the same epsilon over eight districts and over two thousand cells describes completely different disclosures.

Frequently Asked Questions

Why does the domain size matter so much?

Because the client must spread its lie across every other cell. With d cells, the probability of reporting the truth is e^ε/(e^ε + d − 1), which falls roughly as 1/d. Direct randomised response is therefore only practical for small domains; larger grids need unary encoding or local hashing, whose variance does not grow the same way.

Can the client just skip reporting when it is somewhere sensitive?

No — silence is a signal. If the client reports everywhere except sensitive cells, the absence of a report becomes evidence, and the guarantee is void. The client must either report under the mechanism or stop reporting entirely for the epoch on a schedule that does not depend on where it is.

How does this compose with repeated reporting?

Each independent randomisation of the same value composes, so repeated fresh draws destroy the guarantee quickly. Memoise: randomise a given cell once and reuse that output for every subsequent report of the same cell, which is what makes a per-report epsilon a per-distinct-value epsilon.

Is randomized response enough on its own for a compliance claim?

It is a strong technical control, but the claim also depends on client integrity, memoisation, a distinct-value cap, and suppression of small cells. Document all five; the mechanism alone is the part auditors find easiest to verify and the least likely to be where a deployment fails.

Up one level: Local Differential Privacy for Mobile Clients · Section: Differential Privacy for Geospatial Data.