Suppressing Home and Work Anchors in GPS Logs
The two longest-dwell clusters in a trace are almost always the residence and the workplace, and together they identify a person more reliably than any other pair of points. Suppressing them means removing the stop, the approach and departure legs inside a buffer, and the recurrence signal — because any one of the three reconstructs the other two.
Core Calculation permalink
Anchors are inferred from when a cluster is occupied, not from where it is. For a candidate cluster made of stays , the home score is the share of its dwell mass falling in a night window :
and the workplace score is the same over a weekday-daytime window , computed on clusters other than the home. Both need a distinct-day floor: a cluster observed on one night is a hotel, not a residence, and scoring it as home suppresses the wrong place while leaving the real one published.
Suppression then has three parts, and omitting any one of them leaves the anchor recoverable:
- The stays themselves — every stay in the anchor cluster.
- The approach geometry — every transit fix within a buffer of the anchor centroid, because the legs either side extend to their intersection, as shown on the stop-location and POI suppression page.
- The recurrence signal — the fact that a gap appears at the same time every day. A trace whose fixes stop at 18:40 and resume at 07:50 on twenty consecutive weekdays has published a residence-shaped hole.
The buffer must exceed the anchor’s own roaming radius, or the ring of retained fixes traces the cluster’s edge:
where is a typical approach speed and the sampling interval — the extra term stops the first retained fix from landing just outside the roaming radius and pointing straight at it.
Worked numeric example permalink
One user, 28 days, s, m, distinct days:
| Cluster | Total dwell | Night share | Weekday-day share | Distinct days | Verdict |
|---|---|---|---|---|---|
| A | 214 h | 0.91 | 0.02 | 27 | home |
| B | 138 h | 0.04 | 0.88 | 21 | workplace |
| C | 11 h | 0.62 | 0.10 | 2 | below day floor — not an anchor |
| D | 6 h | 0.08 | 0.31 | 9 | ordinary stop |
Cluster C is the interesting one. Its night share of 0.62 is high enough to look residential, and it appears on two days — a weekend stay with friends. Suppressing it as a home would remove a genuinely sensitive stop for the wrong reason and, more damagingly, would leave cluster A published because only one home is suppressed. The day floor is what keeps the ranking honest.
With m/s, the buffer is m.
Python Implementation permalink
from __future__ import annotations
import numpy as np
import pandas as pd
NIGHT = (22, 6) # inclusive start hour, exclusive end hour, wraps midnight
WORKDAY = (9, 17)
def suppress_anchors(
stays: pd.DataFrame,
fixes: pd.DataFrame,
roaming_radius_m: float = 150.0,
sampling_s: float = 60.0,
typical_speed_mps: float = 8.0,
min_distinct_days: int = 5,
) -> dict:
"""Identify home and work anchors and remove them, their buffers and their gaps.
Suppressing the stay alone leaves the approach and departure legs pointing at
it, so the buffer is not optional. Both frames must be in a projected metric
CRS; the buffer is metres.
Args:
stays: `user_id`, `x`, `y`, `t_start`, `t_end`, `cluster_id`.
fixes: `user_id`, `x`, `y`, `t` — the full trace, transit included.
"""
buffer_m = roaming_radius_m + typical_speed_mps * sampling_s
s = stays.copy()
s["dwell_s"] = s["t_end"] - s["t_start"]
hour = pd.to_datetime(s["t_start"], unit="s").dt.hour
dow = pd.to_datetime(s["t_start"], unit="s").dt.dayofweek
s["is_night"] = (hour >= NIGHT[0]) | (hour < NIGHT[1])
s["is_workday"] = (dow < 5) & (hour >= WORKDAY[0]) & (hour < WORKDAY[1])
s["day"] = pd.to_datetime(s["t_start"], unit="s").dt.date
grp = s.groupby(["user_id", "cluster_id"])
prof = grp.agg(
dwell=("dwell_s", "sum"),
night=("dwell_s", lambda v: 0.0), # replaced below, kept for column order
days=("day", "nunique"),
x=("x", "mean"), y=("y", "mean"),
).reset_index()
prof["night"] = (grp.apply(lambda g: (g.dwell_s * g.is_night).sum() / g.dwell_s.sum())
.reset_index(drop=True))
prof["work"] = (grp.apply(lambda g: (g.dwell_s * g.is_workday).sum() / g.dwell_s.sum())
.reset_index(drop=True))
eligible = prof[prof["days"] >= min_distinct_days]
anchors = []
for uid, g in eligible.groupby("user_id"):
home = g.loc[g["night"].idxmax()] if len(g) else None
rest = g.drop(index=home.name) if home is not None else g
work = rest.loc[rest["work"].idxmax()] if len(rest) else None
for role, row in (("home", home), ("work", work)):
if row is not None:
anchors.append({"user_id": uid, "role": role,
"cluster_id": row["cluster_id"],
"x": row["x"], "y": row["y"]})
anchors = pd.DataFrame(anchors)
keep = fixes.copy()
for _, a in anchors.iterrows():
m = keep["user_id"] == a["user_id"]
d = np.hypot(keep.loc[m, "x"] - a["x"], keep.loc[m, "y"] - a["y"])
keep = keep.drop(index=keep.loc[m].index[d <= buffer_m])
return {"anchors": anchors, "fixes": keep, "buffer_m": buffer_m,
"fixes_removed": len(fixes) - len(keep)}
Verification permalink
Three checks, and only the third tests what the control is actually for.
def anchor_recoverable(released_fixes: pd.DataFrame, true_anchor: tuple[float, float],
tolerance_m: float = 300.0) -> dict:
"""Can the anchor be recovered from what survived? Two attacks, one answer."""
x, y = true_anchor
# 1. Nearest retained fix — how close does the release still get?
d = np.hypot(released_fixes["x"] - x, released_fixes["y"] - y)
nearest = float(d.min()) if len(d) else float("inf")
# 2. Centroid of the fixes bracketing the daily gap — the interpolation attack.
g = released_fixes.sort_values("t")
gaps = g["t"].diff()
big = gaps.nlargest(5).index
guess_x = float(g.loc[big, "x"].mean()); guess_y = float(g.loc[big, "y"].mean())
return {
"nearest_retained_fix_m": nearest,
"gap_interpolation_error_m": float(np.hypot(guess_x - x, guess_y - y)),
"protected": bool(nearest > tolerance_m),
}
The gap_interpolation_error_m figure is the one that fails when the buffer is right and the recurrence signal was left in place. A trace with a clean 13-hour hole every night, bracketed by fixes at the same two points, hands the anchor back with an error well under 300 m even though no fix within the buffer survives.
The third check is longitudinal: run the whole anchor inference again on the released trace. If it still finds a top-scoring night cluster, the suppression removed a place and left a pattern.
Edge Cases & Adjustments permalink
- Shift workers and night staff. A fixed 22:00–06:00 window mis-assigns the anchors of anyone who sleeps during the day, so the real residence stays published. Detect an inverted dwell schedule per user and rotate the window rather than trusting a global rule.
- Multi-residence users. Students, shared custody and second homes produce two clusters with similar night scores. Suppress every cluster above a night-score threshold rather than only the argmax; suppressing one of two residences protects nobody.
- Users with too few days. Below the day floor no anchor can be inferred, and the safe default is to generalise all stops for that user. A sparse user with one distinctive stop is more identifiable than a dense one, not less.
- Buffer overlap with genuine destinations. A 630 m buffer around a city-centre workplace can swallow the shops and stations a rider actually used. Record the fraction of non-anchor stops lost to buffers; if it is large, the anchor is in a dense area and a smaller buffer plus stronger gap masking is the better trade.
- Interaction with the k floor. Suppressed anchors leave holes that shift counts in the surrounding cells, so run the k-anonymity check after suppression, not before.
FAQ permalink
Why suppress the workplace at all — is it sensitive?
Individually, less so than the home. In combination it is decisive: the home–work pair is close to unique across a metropolitan population, so publishing either one alongside a suppressed other still narrows the candidate set enormously. Suppress both or neither.
Can I generalise the anchors instead of removing them?
Yes, and it is often better: replacing the anchor with a coarse cell keeps the trip structure intact while removing the address. It only works if the cell is large enough to hold many residences, which in a low-density area means a cell so large the trip structure is meaningless anyway.
How do I mask the recurring gap?
Split the trace into unlinked daily segments with re-salted identifiers, so the nightly hole is no longer attributable to one continuing trace. That is the same pseudonym-rotation discipline described under mix zones and path confusion, applied at the day boundary instead of at an intersection.
Does this replace stay-point suppression generally?
No. Anchors are the two highest-value stops; the sensitivity scoring pass still has to run over everything else, because a clinic visit is sensitive regardless of how briefly it lasted.
Related permalink
- Stop-Location & POI Suppression — the detection and suppression framework this specialises
- Detecting Stay-Points with DBSCAN for Suppression — producing the clusters this scores
- Scoring POI Sensitivity for Location Datasets — ranking the stops that are not anchors
- Mix Zones & Path Confusion — the pseudonym rotation that masks the recurring gap
← Back to Stop-Location & POI Suppression