Differentially Private Stay-Point and POI Extraction
A raw trajectory is mostly travel, and travel is comparatively uninteresting; what analysts want — and what adversaries want — are the stay points, the places a device stopped for long enough to be doing something. Extracting them differentially privately is harder than noising a coordinate, because a stay point is derived from a sequence of observations, and the derivation itself is where the sensitivity hides. This guide builds the extractor with its accounting attached, under DP for spatial trajectories in Differential Privacy for Geospatial Data.
Parameter Configuration and Calibration
dwell_secondsandradius_m— what counts as a stay. The classic definition: a maximal run of consecutive points that stays withinradius_mof its own centroid for at leastdwell_seconds. Typical values are 150 m and 300 s for urban work. Both are privacy-relevant, not just analytical: a shorter dwell threshold produces more stay points per device, which raises the per-device sensitivity of every downstream count.max_stays_per_device— the contribution bound. The parameter that makes the release accountable. One device with a 30-stay day contributes 30 times to the POI histogram; capping to, say, 8 and sampling within the cap bounds sensitivity at 8. Without this, any claimed is arbitrary.epsilon_split— how budget is divided between the two releases. Stay-point extraction produces two publishable artefacts: the count of stays per cell, and the dwell distribution within each cell. They are separate releases over the same data and compose sequentially. A 70/30 split favouring counts is the usual starting point, because dwell statistics tolerate more noise.min_devices_per_poi— the anonymity floor for a published POI. A stay-point cluster visited by two devices is a disclosure regardless of noise. Suppress below the floor, and note that the floor applies to distinct devices, not to stay count — one device visiting fifty times is one device.
| Choice | Analytical effect | Privacy effect |
|---|---|---|
| Shorter dwell threshold | more, briefer stays | higher per-device sensitivity |
| Larger stay radius | merges adjacent stays | fewer stays, coarser POIs |
| Higher stay cap | better coverage of active devices | proportionally more noise everywhere |
| Finer publishing grid | precise POI location | more cells below the device floor |
Reference Implementation
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from typing import Iterable, Sequence
@dataclass(frozen=True)
class Fix:
"""One timestamped location fix in metres (projected, not degrees)."""
t: float
x: float
y: float
@dataclass(frozen=True)
class Stay:
"""A detected stay point: centroid, duration, and the fixes it consumed."""
x: float
y: float
start: float
end: float
@property
def dwell(self) -> float:
return self.end - self.start
def extract_stays(fixes: Sequence[Fix], *, radius_m: float, dwell_s: float) -> list[Stay]:
"""Detect maximal runs that stay within `radius_m` of their own centroid.
Deliberately greedy and deterministic: a randomised extractor would make the
per-device stay count data-dependent in a way that is hard to bound, and the
bound is what the accounting rests on.
"""
stays: list[Stay] = []
i = 0
n = len(fixes)
while i < n:
j = i + 1
cx, cy = fixes[i].x, fixes[i].y
while j < n:
k = j - i + 1
nx = (cx * (k - 1) + fixes[j].x) / k
ny = (cy * (k - 1) + fixes[j].y) / k
if max(math.hypot(f.x - nx, f.y - ny) for f in fixes[i:j + 1]) > radius_m:
break
cx, cy = nx, ny
j += 1
if fixes[j - 1].t - fixes[i].t >= dwell_s:
stays.append(Stay(cx, cy, fixes[i].t, fixes[j - 1].t))
i = j
else:
i += 1
return stays
def bound_contribution(
stays: Sequence[Stay], *, cap: int, seed: int
) -> list[Stay]:
"""Sample at most `cap` stays per device, uniformly and without replacement.
Uniform sampling — rather than taking the longest or the first `cap` stays —
keeps the released distribution unbiased with respect to dwell time, which a
"keep the longest" rule would skew toward workplaces and homes.
"""
if len(stays) <= cap:
return list(stays)
rng = random.Random(seed)
return rng.sample(list(stays), cap)
def poi_histogram(
per_device: Iterable[Sequence[Stay]],
*,
cell_of: "callable",
cap: int,
epsilon: float,
min_devices: int,
seed: int = 0,
) -> dict[str, float]:
"""Release a noisy per-cell stay count with a distinct-device floor.
Sensitivity is `cap`, not 1: one device may contribute up to `cap` stays across
the grid, so the Laplace scale must be cap/epsilon. Getting this wrong is the
most common defect in stay-point releases.
"""
rng = random.Random(seed)
counts: dict[str, float] = {}
devices: dict[str, set[int]] = {}
for device_id, stays in enumerate(per_device):
for stay in bound_contribution(stays, cap=cap, seed=seed + device_id):
cell = cell_of(stay)
counts[cell] = counts.get(cell, 0.0) + 1.0
devices.setdefault(cell, set()).add(device_id)
scale = cap / epsilon
released: dict[str, float] = {}
for cell, count in counts.items():
if len(devices[cell]) < min_devices:
continue # suppressed: too few distinct visitors
u = rng.random() - 0.5
noise = -scale * math.copysign(1.0, u) * math.log1p(-2 * abs(u))
released[cell] = max(0.0, count + noise)
return released
Validation Checkpoint
def _validate() -> None:
# A synthetic device: 20 minutes parked, a drive, then 40 minutes parked.
fixes = (
[Fix(t=60.0 * i, x=100.0 + (i % 2), y=200.0) for i in range(20)]
+ [Fix(t=1_200.0 + 60.0 * i, x=100.0 + 400.0 * i, y=200.0 + 300.0 * i) for i in range(8)]
+ [Fix(t=1_700.0 + 60.0 * i, x=3_300.0, y=2_600.0 + (i % 2)) for i in range(40)]
)
stays = extract_stays(fixes, radius_m=150.0, dwell_s=300.0)
# 1. Both stays are found, and the drive between them is not one.
assert len(stays) == 2, [(s.x, s.dwell) for s in stays]
assert all(s.dwell >= 300.0 for s in stays)
# 2. The contribution cap is enforced exactly.
many = [Stay(float(i), 0.0, 0.0, 600.0) for i in range(30)]
assert len(bound_contribution(many, cap=8, seed=1)) == 8
# 3. Sampling must be unbiased with respect to dwell, not "longest first".
mixed = [Stay(float(i), 0.0, 0.0, 300.0 + 60.0 * i) for i in range(40)]
picked = bound_contribution(mixed, cap=8, seed=2)
assert min(s.dwell for s in picked) < 900.0, "sampling must reach short stays"
# 4. Sensitivity must scale with the cap, not be fixed at 1.
devices = [[Stay(0.0, 0.0, 0.0, 600.0)] * 5 for _ in range(400)]
tight = poi_histogram(devices, cell_of=lambda s: "c0", cap=1,
epsilon=1.0, min_devices=5, seed=3)
loose = poi_histogram(devices, cell_of=lambda s: "c0", cap=5,
epsilon=1.0, min_devices=5, seed=3)
assert tight["c0"] < loose["c0"], "a larger cap admits more stays per device"
# 5. A cell visited by too few distinct devices must be suppressed.
sparse = [[Stay(0.0, 0.0, 0.0, 600.0)] for _ in range(3)]
assert poi_histogram(sparse, cell_of=lambda s: "c9", cap=1,
epsilon=1.0, min_devices=5) == {}
print("stay-point extraction: all assertions passed")
_validate()
Assertion 5 is the control that regulators care about and engineers most often omit: a POI is only publishable if enough distinct devices visited it. Noise does not fix a cell visited by three people, because the adversary’s uncertainty about who those three are was never large.
Incident Response and Edge Cases
- A “stay” appears in the middle of a motorway. Traffic. Either accept it as a genuine stay, or filter by speed before extraction — but note that a speed filter is data-dependent and must not be applied differently per device, or the contribution bound stops holding uniformly.
- A device with a jittery GNSS fix produces dozens of micro-stays. The stay radius is smaller than the device’s position error. Set the radius from the 90th-percentile GNSS error of the fleet, not from a cartographic intuition; a radius below the noise floor manufactures stays.
- The released histogram is dominated by transport hubs. Expected, and usually fine — but check that it is not an artefact of the cap. If devices routinely exceed the cap, uniform sampling under-represents devices with many stays, and hubs (visited by everyone) survive while neighbourhood POIs (visited by few) are sampled away.
- Two releases from overlapping windows. Stay points from overlapping time ranges are correlated, and their budgets compose. Publish on disjoint windows, or account for the overlap explicitly rather than treating each release as fresh.
- Analysts ask for the dwell distribution per POI. That is a second release over the same data, with its own sensitivity — the dwell of one visit is bounded only by the window length, so clip it before releasing quantiles, and debit the budget separately.
Frequently Asked Questions
Why is stay-point sensitivity not 1?
Because one device contributes many stays. Neighbouring datasets differ by all of one device's data, so the histogram can move by the number of stays that device contributed — the contribution cap. Calibrating noise as if sensitivity were 1 understates the required noise by exactly that factor.
Should the extraction itself be differentially private?
It does not need to be, provided it runs inside the trust boundary and only its output is released. Making the extractor randomised would complicate the contribution bound without improving the guarantee, since the guarantee is enforced at the release, not at the intermediate step.
How should overnight stays be handled?
As the most sensitive stay class there is, because an overnight stay is a home address. Many deployments exclude the local night window from POI releases entirely, or publish it only at a much coarser resolution with a higher device floor. Treat it as a separate release with its own parameters rather than as one bucket among many.
Can I publish stay points without a grid?
Not safely. A released centroid is a coordinate, and its precision is whatever the extractor produced. Snap to the publishing grid before releasing, and choose that grid from the density-aware resolution rule rather than from the extractor's radius.
Related
- DP for Spatial Trajectories — the parent method and its budget model.
- W-Event Privacy for Streaming Trajectories — the streaming counterpart to this batch extraction.
- Simulating Trajectory Reconstruction Attacks — testing whether the released stays can be relinked.
- Density-Aware Sensitivity Scoring with H3 — choosing the publishing resolution for the POI grid.
Up one level: DP for Spatial Trajectories · Section: Differential Privacy for Geospatial Data.