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 , 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 . The dominating parameter. Truth-telling probability is , so at 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 into a per-client-per-epoch guarantee of . It is enforced on the device.min_reporters— the per-cell suppression floor. Estimator variance is fixed by and , so a cell with few reporters has an error bar wider than any plausible count. Suppress rather than publish.
| Domain size | at ε = 1 | at ε = 2 | 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 |
Reference Implementation
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
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()
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 and 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 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 , 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 or enlarge the domain.
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.
Related
- Local Differential Privacy for Mobile Clients — the parent design and its mechanism choices.
- Frequency Estimation of Visited Cells with LDP — larger domains and better estimators.
- On-Device Budget Management for LDP Clients — memoisation and the distinct-value cap.
- Comparing Central vs Local Differential Privacy for GIS — when to accept the √n penalty at all.
Up one level: Local Differential Privacy for Mobile Clients · Section: Differential Privacy for Geospatial Data.