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_seconds and radius_m — what counts as a stay. The classic definition: a maximal run of consecutive points that stays within radius_m of its own centroid for at least dwell_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 ε\varepsilon 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
Stay-point sensitivity is the contribution cap, not one A grouped bar chart across five caps on the number of stays a device may contribute. The Laplace noise scale rises linearly with the cap because the cap is the sensitivity, while the share of a day's stays retained saturates quickly. A cap of eight retains roughly three quarters of stays at a fraction of the noise a cap of fifty would require. Stay-point sensitivity is the contribution cap, not one ε = 1.0; a device with a busy day contributes many stays 0 50 100 scale / % 1 15 cap = 1 3 39 cap = 3 8 74 cap = 8 20 96 cap = 20 50 100 cap = 50 Laplace scale (sensitivity/ε) % of a day's stays retained
Calibrating stay-point noise as if sensitivity were 1 understates it by exactly the cap. This is the most common defect in POI releases.

Reference Implementation

python
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

python
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()
The stay radius decides how many stays exist at all A grouped bar chart of stays detected and stays merged away across five stay radii for a single device-day. A 50 metre radius detects twenty-two stays, many of them GNSS jitter around a single location. A 300 metre radius detects five, having merged nine adjacent detections into their neighbours. The radius therefore sets the per-device sensitivity before any privacy parameter is chosen. The stay radius decides how many stays exist at all one device-day, 300 s dwell threshold 0 10 20 stays per device-day 22 0 50 m 14 2 100 m 9 4 150 m 5 9 300 m 3 14 600 m stays detected stays merged away
Set the radius from the fleet's 90th-percentile GNSS error. A radius below the noise floor manufactures stays — and each manufactured stay raises sensitivity.

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.
The distinct-device floor is what noise cannot substitute for Two falling curves against the minimum distinct-device floor. The number of published POIs falls steeply, from 1,840 with no floor to 140 at a floor of twenty-five. The share of published POIs visited by three or fewer devices falls to near zero by a floor of five, which is the disclosure the floor exists to prevent. The distinct-device floor is what noise cannot substitute for POI grid over one metro week 5 10 15 20 25 0 500 1000 1500 minimum distinct devices per POI POIs / % POIs published share visited by ≤ 3 devices (%)
Noise does not help a POI visited by three people: the adversary's uncertainty about who was never large. Only the device floor addresses it.

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.

Up one level: DP for Spatial Trajectories · Section: Differential Privacy for Geospatial Data.