Implementing Randomized Response for Location Check-ins
Encode each check-in into a fixed H3 cell alphabet, keep the true cell with probability (otherwise report a random cell), and recover unbiased per-cell frequencies on the server with — sizing the user count against the variance formula so your target error bound is actually met.
This is the concrete, single-mechanism recipe behind local differential privacy for mobile clients: the raw check-in coordinate never leaves the device, and the aggregator only ever sees randomized cells. A check-in is the ideal fit for randomized response because it is naturally discrete — a user is “at” one venue or map cell at a time, so the reporting domain is a finite alphabet of cells rather than a continuous coordinate. That discreteness is exactly what lets the server invert the randomization into an unbiased frequency estimate, which a continuous perturbation such as geo-indistinguishability cannot do directly. The whole design reduces to three numbers you must get right: the local budget , the alphabet size , and the user population .
Core Calculation permalink
Flip probabilities from epsilon permalink
Generalized randomized response over a -cell alphabet keeps the true cell with probability and otherwise reports one of the other cells uniformly, each with probability :
Unbiased estimator permalink
The observed count of reports naming cell is inflated by lies from the other cells. Inverting the randomization gives an exactly unbiased count and frequency:
Variance and required n permalink
For a cell in the low-frequency regime, the estimator’s variance and standard error are:
Solving for the population needed to hit a target standard error :
Parameter reference permalink
| Symbol | Meaning | Example value |
|---|---|---|
| Local privacy budget per report | 2 | |
| Cell alphabet size | 100 | |
| Truthful probability | 0.0695 | |
| Per-other-cell lie probability | 0.00942 | |
| Number of users / check-ins | 10 000 | |
| Target standard error | 0.01 |
Worked numeric example permalink
Take , a cell alphabet, and check-ins. With :
So at 10 000 users each cell frequency carries roughly a percentage-point standard error. To tighten that to :
Reaching a one-percentage-point error needs about 2.6× the 10 000-user pilot — a direct illustration of the scaling that governs every local-model deployment.
Two facts fall out of this example that are worth internalizing before you write any code. First, the keep probability is tiny: at a 100-cell alphabet and , an individual device tells the truth only about 7% of the time, and the remaining 93% is uniform noise. No single report is informative — utility comes entirely from aggregating tens of thousands of them. Second, the variance is dominated by the alphabet term far more than by in this regime: doubling the cell count from 100 to 200 nearly doubles the required , whereas raising from 2 to 3 (a meaningful privacy relaxation) barely moves it. The practical lesson is to keep the reporting alphabet as small as the analysis will tolerate, and to spend extra population budget on resolution only where the map genuinely needs it.
Python Implementation permalink
The block below runs the whole loop: encode a WGS84 check-in to an H3 cell, randomize on the device at the target , then aggregate and de-bias server-side. It uses numpy, pandas, and the h3 v4 API.
from __future__ import annotations
import h3 # h3 v4 API
import numpy as np
import pandas as pd
from typing import Sequence
H3_RESOLUTION: int = 8 # ~0.46 km^2 hexes; res sets the alphabet size d
def encode_checkin(lat: float, lon: float, alphabet: Sequence[str]) -> int:
"""Map a WGS84 (EPSG:4326) check-in to an index in the shared H3 alphabet.
The raw coordinate is used only on the device. The final alphabet slot is
an 'other' bucket so every real-world cell maps somewhere and the domain
size d stays fixed -- a precondition for exact de-biasing.
"""
cell = h3.latlng_to_cell(lat, lon, H3_RESOLUTION)
return alphabet.index(cell) if cell in alphabet else len(alphabet) - 1
def randomize_checkin(true_index: int, epsilon: float, d: int,
rng: np.random.Generator) -> int:
"""Generalized randomized response over a d-cell alphabet.
Keeps the true cell with p = e^eps / (e^eps + d - 1); otherwise reports a
uniformly random *different* cell. The output alone satisfies epsilon-local
differential privacy, so a single check-in is deniable and the raw location
cannot be recovered from it.
"""
p_keep = np.exp(epsilon) / (np.exp(epsilon) + d - 1)
if rng.random() < p_keep:
return true_index # truthful report
other = int(rng.integers(0, d - 1)) # uniform over the d-1 others
return other if other < true_index else other + 1
def debias_counts(reports: np.ndarray, epsilon: float, d: int) -> pd.DataFrame:
"""Recover unbiased per-cell frequencies from randomized check-ins.
Applies c_hat = (observed - n*q) / (p - q), which is exactly unbiased for
the true per-cell count. Negative de-biased frequencies (noise around empty
cells) are clipped to zero -- valid post-processing that keeps the DP
guarantee intact.
"""
n = reports.size
p = np.exp(epsilon) / (np.exp(epsilon) + d - 1)
q = 1.0 / (np.exp(epsilon) + d - 1)
observed = np.bincount(reports, minlength=d).astype(float)
debiased = (observed - n * q) / (p - q)
return pd.DataFrame({
"cell_index": np.arange(d),
"debiased_freq": np.clip(debiased / n, 0.0, None),
})
Note that randomize_checkin runs on the handset; only its integer output is uploaded. The server calls debias_counts on the collected reports and never touches a raw coordinate. Three implementation details carry the privacy guarantee. The p_keep comparison against a single uniform draw is the entire mechanism — there is no seed shared with the server, so the operator cannot reverse a specific device’s coin flip. The other if other < true_index else other + 1 idiom draws uniformly from the non-true cells without ever materializing the true index in the output when the lie branch is taken. And debias_counts subtracts exactly , the expected number of spurious reports each cell receives from the lie branch, which is what makes hold precisely rather than approximately.
If you later move this to production, the client function is what ships inside the mobile app, ideally in a shared native library so the flip probabilities cannot drift, while debias_counts runs in the ingestion pipeline. The one invariant both sides must share is the ordered alphabet and its length ; everything else is derived from and at call time.
Verification permalink
Simulate a known ground-truth distribution, push every user through the encode-randomize-debias loop, and confirm the recovered frequencies fall within the error predicted by the variance formula.
def verify(true_freq: np.ndarray, epsilon: float, n_users: int,
seed: int = 0) -> dict:
"""Confirm unbiased recovery and that error tracks the theoretical SE."""
rng = np.random.default_rng(seed)
d = true_freq.size
true_cells = rng.choice(d, size=n_users, p=true_freq) # ground truth
reports = np.fromiter(
(randomize_checkin(int(c), epsilon, d, rng) for c in true_cells),
dtype=np.int64, count=n_users,
)
est = debias_counts(reports, epsilon, d)["debiased_freq"].to_numpy()
theo_se = np.sqrt((d - 2 + np.exp(epsilon)) /
(n_users * (np.exp(epsilon) - 1) ** 2))
return {
"mean_abs_error": float(np.mean(np.abs(est - true_freq))),
"theoretical_se": float(theo_se),
}
# Worked example: d=100, eps=2, n=10_000 -> theoretical SE ~ 0.0161
rng = np.random.default_rng(7)
tf = rng.dirichlet(np.ones(100))
print(verify(tf, epsilon=2.0, n_users=10_000))
# mean_abs_error should land within ~1x of theoretical_se (~0.016)
If mean_abs_error sits near theoretical_se, the estimator is unbiased and correctly calibrated. A much larger error means is too small for the alphabet or the client/server alphabets have drifted apart.
Edge Cases & Adjustments permalink
- Small . Below a few thousand users the de-biased histogram is dominated by noise; the estimator stays unbiased but its variance swamps the signal. Suppress released cells whose de-biased count falls under a documented floor rather than publishing noisy near-zero cells, and re-check the required- formula before shipping.
- Large cell alphabet. Variance grows with , so pushing H3 resolution up to sharpen the map inflates the standard error. When gets large (), switch from direct encoding to RAPPOR-style unary encoding, or report a coarse cell first and refine only well-populated regions.
- Repeated daily reports (composition). One check-in is -LDP, but a user who checks in every day spends each time; 30 days at composes to . Memoize a persistent randomized answer per stable location, or track cumulative spend with the privacy-budget accounting used for spatial queries.
- CRS and H3 resolution. Encode from WGS84 (EPSG:4326) exactly as the device GPS emits it — H3 handles the spherical math, so no reprojection to a metric CRS is needed before indexing. Pin
H3_RESOLUTIONand the ordered alphabet in a versioned config; a resolution or ordering mismatch between client and server silently corrupts and biases every estimate.
FAQ permalink
What local epsilon should I use for location check-ins?
Values between 1 and 3 per report are typical for check-in histograms. Lower is more private but needs far more users to reach a usable error bound, as the required- formula makes explicit. Whatever you pick, pair it with a per-device composition budget, because repeated check-ins spend each time.
How many users do I need for a one-percentage-point error?
Use . For a 100-cell alphabet at and , that is about 25,800 users. Halving the target error quadruples the population, so budget users against the accuracy you actually need — see the broader accuracy vs. utility tradeoffs in geospatial DP.
Why can a de-biased cell count come out negative?
The estimator subtracts the expected noise floor , so genuinely empty cells fluctuate around zero and occasionally land slightly negative. Clipping to zero is valid post-processing that does not weaken the guarantee. Do not read a clipped-to-zero cell as a confirmed absence from a single release.
Does this leak the raw check-in coordinate?
No. Randomization happens on the device before upload, so the aggregator sees only a perturbed cell index. This is what lets the check-in flow support GDPR data-minimisation and de-identification obligations — there is no central store of true locations.
Related
- Local Differential Privacy for Mobile Clients — the full central-vs-local picture behind this recipe
- Privacy Budget Allocation for Spatial Queries — tracking ε as repeated check-ins compose
- Accuracy vs. Utility Tradeoffs in Geospatial DP — sizing error against usable spatial detail
- Laplace & Gaussian Noise for Coordinate Data — the central-model mechanism this local approach replaces
- Compliance Mapping for GDPR & CCPA Location Data — documenting the on-device guarantee