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.
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 for at least a dwell time . Given an ordered trace with in a projected metric CRS, a candidate stay-point begins at anchor and extends to the largest such that:
The run qualifies as a stay-point when its temporal span meets the dwell threshold:
Each detected stay-point is summarized by its centroid and interval:
Home & Workplace Heuristics permalink
Home is inferred as the stay location whose visits concentrate in night-time hours. Let be the resident window. For a group of repeated stays, the home score is the fraction of night-time dwell mass:
The home anchor is subject to a minimum number of distinct days observed. Workplace is inferred symmetrically over a weekday daytime window 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 combining its POI category weight and its uniqueness (inverse of how many users share the location), then routes to an action by two thresholds :
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 orlat/lonpair. 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 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.14andshapely ≥ 2.0for spatial joins and generalization,numpyandpandasfor the scoring arithmetic,pyprojfor 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 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 fragments one clinic visit into several short stays that each fail and slip through undetected; too large a 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 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.
Related permalink
- Trajectory Anonymization Techniques — whole-path methods that pair with stop-level suppression
- Temporal Cloaking & Time Obfuscation — coarsen when events occurred alongside where
- POI-Sensitivity Scoring for Location Datasets — build the category rubric that drives the scoring step
- Spatial Fuzzing & Buffer Zone Implementation — thin and displace the transit pings around suppressed stops
- Re-identification Risk Assessment for Geospatial Datasets — validate that anchors are no longer recoverable