Stay-Point & POI Suppression for Mobility Data

A raw GPS trajectory is a sequence of timestamped pings, but its privacy risk is concentrated in the handful of places where a person stops — the home, workplace, clinic, or place of worship whose coordinates uniquely identify the individual and expose sensitive inferences. Stay-point suppression detects those dwell locations, scores how disclosive each one is, and removes or generalizes the sensitive stops before the trace is released.

Where Stay-Point Suppression Fits permalink

Suppression is a targeted intervention: instead of degrading every ping uniformly, it identifies the small set of high-risk anchors and treats them selectively. The pipeline below shows the decision path from raw pings to a released trace.

Stay-point suppression pipeline Raw GPS pings enter stay-point detection governed by a maximum roaming distance and a minimum dwell time, stops are classified and scored for sensitivity, and each stop is routed to a suppress, generalize, or keep outcome before the anonymized trace is emitted. Raw GPS pings Stay-point detection d_max, t_min Classify stops home / work / POI category Sensitivity scoring s(stop) Score band? s vs thresholds Suppress high sensitivity Generalize coarsen to cell Keep low risk Anonymized trace out
Sensitive stops are removed or coarsened while ordinary stops and transit pings pass through. Sensitivity is assigned by a dedicated POI-sensitivity scoring step that ranks each classified stop before the suppress-generalize-keep decision.

Suppression complements — rather than replaces — whole-trajectory methods. Where trajectory anonymization techniques act on the shape of the path and temporal cloaking coarsens when events occurred, stay-point suppression acts on where a person meaningfully dwelled. The three are usually layered.

The reason to single out stops at all is that dwell locations carry disproportionate identifying power. Transit pings between places are shared by everyone travelling a given corridor and are largely interchangeable, but the set of places a person returns to — the home each night, the workplace each weekday, the clinic every third Tuesday — forms a signature that is close to unique across a metropolitan population. Suppression concentrates the privacy budget where the risk actually lives, rather than diluting the whole trace with uniform noise that degrades every ping equally while still leaving the anchors legible.

Algorithmic Specification permalink

Stay-Point Detection permalink

A stay-point is a maximal run of consecutive pings that stay within a roaming radius dmaxd_{\max} for at least a dwell time tmint_{\min}. Given an ordered trace P=p1,p2,,pnP = \langle p_1, p_2, \dots, p_n \rangle with pk=(xk,yk,tk)p_k = (x_k, y_k, t_k) in a projected metric CRS, a candidate stay-point begins at anchor pip_i and extends to the largest jj such that:

maxikj  pkpi2dmax\max_{i \le k \le j} \; \lVert p_k - p_i \rVert_2 \le d_{\max}

The run qualifies as a stay-point when its temporal span meets the dwell threshold:

tjtitmint_j - t_i \ge t_{\min}

Each detected stay-point SS is summarized by its centroid and interval:

pˉS=(1SkSxk,  1SkSyk),ΔtS=tendtstart\bar{p}_S = \left( \frac{1}{|S|}\sum_{k \in S} x_k,\; \frac{1}{|S|}\sum_{k \in S} y_k \right), \qquad \Delta t_S = t_{\text{end}} - t_{\text{start}}

Home & Workplace Heuristics permalink

Home is inferred as the stay location whose visits concentrate in night-time hours. Let N=[22 ⁣: ⁣00,06 ⁣: ⁣00)N = [22\!:\!00, 06\!:\!00) be the resident window. For a group cc of repeated stays, the home score is the fraction of night-time dwell mass:

h(c)=ScΔtS1[tSN]ScΔtSh(c) = \frac{\sum_{S \in c} \Delta t_S \cdot \mathbb{1}[\,t_S \in N\,]}{\sum_{S \in c} \Delta t_S}

The home anchor is argmaxch(c)\arg\max_c h(c) subject to a minimum number of distinct days observed. Workplace is inferred symmetrically over a weekday daytime window W=[09 ⁣: ⁣00,17 ⁣: ⁣00)W = [09\!:\!00, 17\!:\!00) on Monday–Friday, excluding the home cluster. Requiring several distinct observation days matters: it separates a genuine resident anchor, which recurs night after night, from a single overnight stay at a hotel or a friend’s home that would otherwise dominate a short trace. Clusters are formed by rounding stay-point centroids to a coarse spatial key so that repeated visits to the same building collapse into one candidate anchor despite GPS jitter between visits.

Sensitivity Classes permalink

Each classified stop receives a sensitivity score s[0,1]s \in [0,1] combining its POI category weight wcatw_{\text{cat}} and its uniqueness (inverse of how many users share the location), then routes to an action by two thresholds τsup>τgen\tau_{\text{sup}} > \tau_{\text{gen}}:

action(S)={suppresss(S)τsupgeneralizeτgens(S)<τsupkeeps(S)<τgen\text{action}(S) = \begin{cases} \text{suppress} & s(S) \ge \tau_{\text{sup}} \\ \text{generalize} & \tau_{\text{gen}} \le s(S) < \tau_{\text{sup}} \\ \text{keep} & s(S) < \tau_{\text{gen}} \end{cases}

Parameter Reference permalink

Parameter Typical value Privacy effect Utility effect
Roaming radius d_max 150–200 m Larger → merges nearby visits, hides micro-stops Larger → coarser stop location
Min dwell t_min 5–20 min Lower → catches brief sensitive visits (clinics) Lower → more stops, more noise
Night window N 22:00–06:00 Defines home inference Narrower → misses shift workers
Suppress threshold τ_sup 0.7–0.85 Higher → suppress fewer stops Higher → retains more utility
Generalize threshold τ_gen 0.4–0.55 Lower → generalize more stops Lower → coarser mid-risk stops
Generalization cell 500–2000 m Larger → more addresses per cell Larger → weaker place semantics

Prerequisites & Data Requirements permalink

  • Trace schema: one row per ping with a stable user_id, timestamp (timezone-aware), and a geometry or lat/lon pair. Pings must be sortable per user by time; out-of-order or duplicated timestamps break run detection.
  • Projected metric CRS: distance and dwell computations must run in metres, so reproject WGS84 (EPSG:4326) input to a local UTM zone or EPSG:3857 before detection. Computing dmaxd_{\max} in degrees silently distorts the radius with latitude.
  • Minimum observation window: home/work inference needs several distinct days per user (commonly ≥ 5) to separate a resident anchor from an occasional overnight stay.
  • Python dependencies: scikit-mobility (skmob) for stay-point detection, geopandas ≥ 0.14 and shapely ≥ 2.0 for spatial joins and generalization, numpy and pandas for the scoring arithmetic, pyproj for the CRS transform.
  • Reference POI layer (optional but recommended): a categorized point layer (clinics, worship sites, schools) in the same CRS lets you assign category weights by nearest-neighbour join rather than by manual labelling.

Step-by-Step Implementation permalink

Step 1 — Load, Validate, and Project the Trace permalink

import geopandas as gpd
import pandas as pd
import numpy as np
import skmob
from skmob.preprocessing import detection

# Trace stored as one row per ping. WGS84 input; we reproject for metric work.
raw = pd.read_parquet("traces.parquet")  # columns: user_id, lat, lng, datetime

# Enforce a clean, time-ordered schema — unordered pings corrupt run detection.
raw["datetime"] = pd.to_datetime(raw["datetime"], utc=True)
raw = raw.dropna(subset=["user_id", "lat", "lng", "datetime"])
raw = raw.sort_values(["user_id", "datetime"]).reset_index(drop=True)

# scikit-mobility TrajDataFrame keeps lat/lng in WGS84; detection handles metric
# distance internally, so no manual reprojection is needed for stop detection.
tdf = skmob.TrajDataFrame(
    raw, latitude="lat", longitude="lng",
    datetime="datetime", user_id="user_id",
)

Validating the schema here is a privacy control, not just hygiene: a single mis-ordered ping can merge two separate visits into one detected stay-point, either masking a sensitive stop or fabricating one that never happened.

Step 2 — Detect Stay-Points permalink

# d_max = roaming radius (km); t_min = minimum dwell (minutes).
# 0.2 km and 10 min is a balanced urban default; lower t_min to catch
# short but disclosive visits such as a clinic appointment.
stops = detection.stay_locations(
    tdf,
    stop_radius_factor=0.5,     # cluster radius scaling
    minutes_for_a_stop=10.0,    # t_min
    spatial_radius_km=0.2,      # d_max
    leaving_time=True,          # keep arrival AND departure timestamps
)

# Each row is one stay-point: user_id, lat, lng, datetime (arrival),
# leaving_datetime (departure). Compute dwell for downstream scoring.
stops["dwell_min"] = (
    (stops["leaving_datetime"] - stops["datetime"]).dt.total_seconds() / 60.0
)

Keeping both arrival and departure timestamps matters for the re-identification risk assessment later: the dwell interval is itself a quasi-identifier, and a distinctive dwell pattern can single out a user even after the coordinate is coarsened.

Step 3 — Infer Home and Workplace Anchors permalink

def infer_anchors(user_stops: pd.DataFrame) -> dict:
    """Infer home and work anchors for one user from stay-points.

    Home = stay-cluster with the highest share of night-time dwell mass.
    Work = highest weekday-daytime dwell mass, excluding the home cell.
    Coordinates stay in WGS84; clustering tolerance is a coarse rounding.
    """
    s = user_stops.copy()
    s["hour"] = s["datetime"].dt.hour
    s["weekday"] = s["datetime"].dt.weekday  # 0=Mon
    # Coarse spatial key (~110 m) groups repeated visits to the same place.
    s["cell"] = list(zip(s["lat"].round(3), s["lng"].round(3)))

    is_night = (s["hour"] >= 22) | (s["hour"] < 6)
    is_work = (s["hour"].between(9, 17)) & (s["weekday"] < 5)

    night_mass = s[is_night].groupby("cell")["dwell_min"].sum()
    home = night_mass.idxmax() if not night_mass.empty else None

    work_mass = s[is_work & (s["cell"] != home)].groupby("cell")["dwell_min"].sum()
    work = work_mass.idxmax() if not work_mass.empty else None
    return {"home": home, "work": work}

anchors = {uid: infer_anchors(g) for uid, g in stops.groupby("user_id")}

Home and workplace are the two anchors that most reliably re-identify an individual: the pair is close to unique across a population, so both are treated as maximally sensitive regardless of category weight.

Step 4 — Score Stop Sensitivity permalink

# Category weights encode inference risk. Health, worship, and political
# venues permit special-category inference and score near 1.0.
CATEGORY_WEIGHT = {
    "clinic": 0.95, "hospital": 0.9, "worship": 0.9,
    "union_hall": 0.85, "school": 0.7, "shelter": 0.95,
    "home": 1.0, "work": 0.8, "retail": 0.2, "transit": 0.1,
}

def score_stop(row, anchors, poi_join) -> float:
    """Sensitivity in [0,1] from category weight and location uniqueness."""
    cell = (round(row["lat"], 3), round(row["lng"], 3))
    a = anchors.get(row["user_id"], {})
    if cell == a.get("home"):
        category = "home"
    elif cell == a.get("work"):
        category = "work"
    else:
        category = poi_join.get(cell, "retail")  # nearest categorized POI
    w_cat = CATEGORY_WEIGHT.get(category, 0.2)
    # Uniqueness: rarer shared locations are more identifying.
    users_here = poi_join.get(("__shared__", cell), 1)
    uniqueness = 1.0 / users_here
    return float(min(1.0, 0.7 * w_cat + 0.3 * uniqueness))

stops["sensitivity"] = stops.apply(
    lambda r: score_stop(r, anchors, POI_JOIN), axis=1
)

The category weights above are a starting point; calibrate them against a formal rubric using POI-sensitivity scoring for location datasets so the thresholds reflect your jurisdiction’s definition of sensitive categories rather than an ad-hoc list.

Step 5 — Suppress or Generalize Sensitive Stops permalink

from shapely.geometry import Point

TAU_SUP = 0.75   # >= suppress entirely
TAU_GEN = 0.45   # >= generalize to a coarse cell
GEN_CELL_M = 1000  # generalization cell edge (metres, in EPSG:3857)

def apply_action(stops_gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Route each stop to suppress / generalize / keep by sensitivity band.

    Suppression drops the stop AND thins transit pings inside a buffer so
    the removed dwell cannot be interpolated from approach/departure legs.
    """
    keep_rows = []
    for _, r in stops_gdf.iterrows():
        s = r["sensitivity"]
        if s >= TAU_SUP:
            continue  # suppress: emit nothing for this stop
        if s >= TAU_GEN:
            # Snap centroid to a coarse grid cell (spatial generalization).
            gx = (np.floor(r.geometry.x / GEN_CELL_M) + 0.5) * GEN_CELL_M
            gy = (np.floor(r.geometry.y / GEN_CELL_M) + 0.5) * GEN_CELL_M
            r = r.copy()
            r["geometry"] = Point(gx, gy)
            r["generalized"] = True
        keep_rows.append(r)
    return gpd.GeoDataFrame(keep_rows, crs=stops_gdf.crs)

# stops_3857: stay-points reprojected to EPSG:3857 for metric grid snapping.
released = apply_action(stops_3857)

Mid-risk stops are coarsened rather than deleted, which preserves the fact that a visit occurred while defeating an exact reverse-geocode. For the highest-risk stops, deletion alone is insufficient — combine it with spatial fuzzing and buffer zones over the surrounding transit pings, and use k-anonymity grouping for location traces so each generalized cell contains enough distinct users to resist singling-out.

Validation & Re-identification Testing permalink

Home / Work Recoverability permalink

The primary test is adversarial: re-run the anchor inference on the released trace and confirm the home and workplace no longer resolve to a single dwelling.

# Attack simulation: does the night-time centroid still pin the home?
recovered = {uid: infer_anchors(g) for uid, g in released_wgs84.groupby("user_id")}
leaks = [
    uid for uid, a in recovered.items()
    if a["home"] is not None and a["home"] == anchors[uid]["home"]
]
print(f"Homes still recoverable: {len(leaks)} / {len(anchors)}")
# Target: 0. Any non-zero result means suppression left enough night pings
# to reconstruct the anchor — tighten t_min or widen the suppression buffer.

Neighbour Count on Remaining Stops permalink

For every generalized cell that survived, count the distinct users assigned to it. Cells holding fewer than kk users are still singling-out risks and should be merged upward to a coarser cell or suppressed. This mirrors the k-anonymity floor used in aggregate release.

Auxiliary-Join Test permalink

Simulate a linkage attack by joining the released stops against a plausible auxiliary dataset — a public POI directory or a voter-roll address list. If any released coordinate falls within the generalization cell diagonal of a unique auxiliary record, the join reconstructs the original place. Escalate the failing stops’ actions until no unique matches remain; the method and thresholds are covered under re-identification risk assessment for geospatial datasets.

Common Failure Modes & Gotchas permalink

Over-suppression destroys utility. Deleting every stop above a low threshold collapses the trace into disconnected transit fragments that are useless for mobility analysis. Reserve outright suppression for the highest sensitivity band and route the middle band to generalization, which retains the visit’s existence and approximate location.

Residual inference from surrounding pings. A suppressed stop still casts a shadow: the approach and departure legs point straight at the deleted location, and a straightforward interpolation recovers it. Always thin or fuzz the transit pings inside a buffer around each suppressed stop, not just the stop itself.

Threshold mis-tuning splits or merges visits. Too small a dmaxd_{\max} fragments one clinic visit into several short stays that each fail tmint_{\min} and slip through undetected; too large a dmaxd_{\max} merges a sensitive stop with a neighbouring benign one and averages away its risk. Tune both thresholds against a labelled sample before production.

Night-window assumptions break for shift workers. A fixed 22:00–06:00 resident window mis-assigns home for anyone who sleeps during the day, potentially leaving the true home un-suppressed. Detect atypical dwell schedules and widen or personalize the window rather than trusting a single global rule.

Sparse users defeat home inference — and that is a risk, not a relief. When a user has too few days to infer an anchor, the pipeline may leave every stop as “keep.” A user with one distinctive stop is more identifiable, not less; apply a conservative default that generalizes all stops for under-observed users.

Compliance Alignment permalink

Control Satisfied by
GDPR Art. 9 — special-category data Suppressing clinic, worship, and union stops removes the coordinates that permit health, religion, and membership inference
GDPR Art. 5(1)© — data minimisation Only non-sensitive stops and thinned transit pings are retained; sensitive dwell coordinates are dropped
GDPR Art. 25 — data protection by design Sensitivity thresholds and the suppress/generalize rule are architectural parameters, versioned and auditable
CCPA/CPRA — sensitive personal information “Precise geolocation” of sensitive venues is generalized below the identifying resolution
HIPAA Safe Harbor (§164.514(b)) Clinic and treatment-facility stops removed so geotagged health context cannot re-link a record
NIST SP 800-188 Suppression parameters, thresholds, and validation results recorded in the de-identification manifest

Because a stop at a clinic supports inference of a health condition, mobility traces containing such stops fall within the scope of both special-category rules and, where the data touches treatment context, health-record de-identification. Align the suppression configuration with the broader GDPR and CCPA location-data compliance mapping, and for datasets that intersect clinical settings follow the specific procedure for de-identifying HIPAA geotagged health records.

FAQ permalink

How do I choose between suppressing a stop and generalizing it? Suppress when the stop’s presence alone is disclosive — a clinic, a shelter, a place of worship — because even a coarse cell that reveals “someone visited a clinic here” is sensitive. Generalize when the category is benign but the precision is the risk, such as a home whose block-level cell contains enough addresses to defeat singling-out.

Can I run stay-point suppression without a reference POI layer? Yes. Home and workplace are inferred from temporal patterns alone, and those two anchors carry the highest re-identification risk. A POI layer only refines the category weights for other stops; without it, fall back to uniqueness-based scoring, which still flags rarely-shared locations.

Does lowering the dwell threshold always improve privacy? No. A lower tmint_{\min} detects more brief-but-sensitive visits, but it also produces many short spurious stays from GPS noise, inflating the number of stops to score and raising the odds that a benign micro-stop is over-suppressed. Balance it against the roaming radius on a labelled sample.

What is the single most common reason suppression fails validation? Leaving the transit pings intact around a deleted stop. The dwell is gone but the trajectory still converges on and departs from the location, so the adversarial re-run recovers the anchor by interpolation. Buffer-thinning the surrounding pings closes that gap.


Back to Trajectory & Mobility Data Privacy