How to test whether GPS traces reveal where people live
On this page
Problem statement
A set of GPS tracks looks anonymous. It has no names, often no user identifier, and the lines cross half a city. It is also, in the literature and in practice, one of the easiest datasets to re-identify: the places a track repeatedly starts and ends are the places its owner repeatedly is, and one of those is usually home.
The test is the attack, run by you first. Cluster the endpoints and the long stationary periods, count how many tracks share each cluster, and see whether the result points at a building. If it does, the dataset needs a defence before it is published — and the defence for trajectories is different from the one for points, because a trajectory is dozens of quasi-identifiers rather than one.
This guide runs that test on 60 track segments from public OpenStreetMap GPS traces in Brighton, and shows what the result does and does not prove.
Quick answer
Cluster the track endpoints and look at what shares a cluster:
import numpy as np
from scipy.spatial import cKDTree
from scipy.sparse.csgraph import connected_components
from scipy.sparse import coo_matrix
ends = np.array([[t[0], t[1]] for t in endpoints]) # projected metres
pairs = np.array(list(cKDTree(ends).query_pairs(50))) # 50 m link distance
graph = coo_matrix((np.ones(len(pairs)), (pairs[:, 0], pairs[:, 1])),
shape=(len(ends), len(ends)))
n, labels = connected_components(graph, directed=False)
sizes = np.bincount(labels)
print(f"{len(ends)} endpoints -> {n} clusters; largest {sizes.max()}; "
f"in a cluster of 3+: {sizes[labels][sizes[labels] >= 3].size / len(ends):.1%}")
On the Brighton traces (Example 2): 120 endpoints from 60 segments collapsed to 70 clusters at 50 m, none holding more than 4, and 30.8% of endpoints fell in a cluster of three or more. Four of those ten clusters were made by one person's own traces alone — the repeated-anchor signature the test is looking for.
What you get — from The data used on this page
12 pages
Segments, metres, anchors, clusters, shares
1. Load the traces and keep the segment structure
A GPX file can hold several tracks and each track several segments. A segment is the unit with a real start and end; concatenating them invents journeys that never happened.
2. Project to metres
Clustering distances are in metres. Reproject once before any geometry work.
3. Extract candidate anchors
Two kinds are worth testing:
- Endpoints — the first and last fix of every segment. Cheap, and often enough.
- Dwells — runs of consecutive fixes within a radius for longer than a threshold. Better, because a track that starts mid-journey still has a dwell at its destination.
def dwells(points, times, radius=50, min_seconds=600):
out, i = [], 0
while i < len(points):
j = i
while j + 1 < len(points) and np.hypot(*(points[j + 1] - points[i])) < radius:
j += 1
if (times[j] - times[i]).total_seconds() >= min_seconds:
out.append(points[i:j + 1].mean(axis=0))
i = max(j, i + 1)
return np.array(out)
4. Cluster the anchors
Single-link clustering at the radius you would call "the same place" — 25–50 m for a building, 100 m for a block. DBSCAN with min_samples=1 does the same job.
5. Count what shares each cluster
The number that matters is how many distinct subjects share a cluster, not how many points. If the dataset has a user identifier, group by it; if it does not, the trace identifier is the best available proxy and the count is an upper bound on privacy.
6. Check the cluster against a land-use layer
A cluster on a residential building is a home. A cluster on a station, a car park or a sports ground is a public place and usually not disclosive. This step is what turns a statistic into a finding — and it depends on the land use being mapped at all. In Brighton, 7 of the 10 shared places fell inside no mapped land-use polygon (Example 4).
7. Test the time-of-day signature
The standard heuristic for "home" is the most-visited dwell between roughly 22:00 and 06:00. If you cannot compute it because the traces carry no timestamps, say so; without it, a cluster is just a repeated place. Every one of the 60 Brighton segments carried timestamps, so here the test is available — which is itself a reason to treat the dataset with care.
8. Decide the defence before publishing
For trajectories, point-level masking is weak because the track constrains where the true point can be. The workable options are truncating the first and last few hundred metres of every track, publishing only aggregated flows, or releasing segments that have been cut at anchors and shuffled between users.
Clustering the endpoints of public GPS traces
The data used on this page
Public GPS traces from the OpenStreetMap trackpoints API for a box over central Brighton and Hove, −0.20 to −0.10° E, 50.81 to 50.86° N: the first 12 pages of 5,000 points, 60,000 points in all. The API serves the most recently uploaded traces first, so a later download returns different traces. Traces uploaded as "identifiable" carry their owner's trace URL; "trackable" ones carry timestamps but no owner. Nothing on this page prints a location or a username — only counts.
import os
import time
import requests
os.makedirs("traces", exist_ok=True)
for page in range(12):
path = f"traces/page_{page:02d}.gpx"
if not os.path.exists(path):
reply = requests.get("https://api.openstreetmap.org/api/0.6/trackpoints",
params={"bbox": "-0.20,50.81,-0.10,50.86", "page": page},
headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120)
reply.raise_for_status()
with open(path, "wb") as out:
out.write(reply.content)
time.sleep(2)
print(len(os.listdir("traces")), "pages")
12 pages
Example 1 — parse GPX into segments
import xml.etree.ElementTree as ET, glob
import numpy as np, pandas as pd
NS = {"g": "http://www.topografix.com/GPX/1/0"}
def read_segments(pattern, min_points=20):
segs = []
for f in sorted(glob.glob(pattern)):
for trk in ET.parse(f).getroot().findall("g:trk", NS):
name = trk.findtext("g:url", default="", namespaces=NS) or \
trk.findtext("g:name", default="", namespaces=NS)
for seg in trk.findall("g:trkseg", NS):
pts = [(float(p.get("lat")), float(p.get("lon")),
p.findtext("g:time", namespaces=NS))
for p in seg.findall("g:trkpt", NS)]
if len(pts) >= min_points:
segs.append((name, pts))
return segs
segments = read_segments("traces/*.gpx")
print(f"{len(segments)} segments, {sum(len(p) for _, p in segments):,} points")
print(f"with timestamps: {sum(p[0][2] is not None for _, p in segments)}; "
f"with an owner's trace URL: {sum('/user/' in n for n, _ in segments)}")
60 segments, 59,967 points
with timestamps: 60; with an owner's trace URL: 52
Example 2 — cluster the endpoints and report
import geopandas as gpd, numpy as np, pandas as pd
from scipy.spatial import cKDTree
rows = []
for i, (name, pts) in enumerate(segments):
rows.append((i, name, pts[0][0], pts[0][1], "start"))
rows.append((i, name, pts[-1][0], pts[-1][1], "end"))
ends = pd.DataFrame(rows, columns=["segment", "trace", "lat", "lon", "which"])
g = gpd.GeoDataFrame(ends, geometry=gpd.points_from_xy(ends.lon, ends.lat),
crs=4326).to_crs(27700)
xy = np.c_[g.geometry.x, g.geometry.y]
parent = list(range(len(xy)))
def find(a):
while parent[a] != a:
parent[a] = parent[parent[a]]
a = parent[a]
return a
for a, b in cKDTree(xy).query_pairs(50):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
labels = np.array([find(i) for i in range(len(xy))])
sizes = pd.Series(labels).value_counts()
print(f"{len(xy)} endpoints -> {len(sizes)} clusters at 50 m")
print(f"clusters with 3+ endpoints: {(sizes >= 3).sum()}; largest {sizes.max()}")
print(f"share of endpoints in a 3+ cluster: {sizes[sizes >= 3].sum() / len(xy):.1%}")
# distinct subjects: the owner where the trace names one, otherwise the segment itself
ends["cluster"] = labels
ends["subject"] = ends.trace.str.extract(r"/user/([^/]+)/")[0].fillna("segment " + ends.segment.astype(str))
per = ends.groupby("cluster").agg(endpoints=("segment", "size"), subjects=("subject", "nunique"))
big = per[per.endpoints >= 3]
print(f"3+ clusters made by one subject alone: {(big.subjects == 1).sum()} of {len(big)}")
120 endpoints -> 70 clusters at 50 m
clusters with 3+ endpoints: 10; largest 4
share of endpoints in a 3+ cluster: 30.8%
3+ clusters made by one subject alone: 4 of 10
Example 3 — truncate the ends before release
import numpy as np
def truncate_ends(points_xy, cut_m=500):
"""Drop fixes within cut_m of the first and last fix, along the path."""
d = np.r_[0, np.cumsum(np.hypot(*np.diff(points_xy, axis=0).T))]
keep = (d > cut_m) & (d < d[-1] - cut_m)
return points_xy[keep]
tracks = []
for _, pts in segments:
t = gpd.GeoSeries(gpd.points_from_xy([p[1] for p in pts], [p[0] for p in pts]), crs=4326).to_crs(27700)
tracks.append(np.c_[t.x, t.y])
kept = [truncate_ends(p) for p in tracks]
print(f"tracks left with fewer than 10 fixes: {sum(len(k) < 10 for k in kept)} of {len(kept)}")
tracks left with fewer than 10 fixes: 11 of 60
Cutting 500 m from each end destroys short trips entirely — here 11 of the 60 segments were left with fewer than ten fixes. Check how many, because a release that silently drops all the local journeys has changed what the dataset is about.
Example 4 — check the shared places against land use
import osmnx as ox
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
landuse = ox.features_from_bbox((-0.20, 50.81, -0.10, 50.86), tags={"landuse": True, "leisure": ["park", "pitch", "sports_centre"]})
landuse = landuse[landuse.geom_type.isin(["Polygon", "MultiPolygon"])].to_crs(27700)
landuse["use"] = landuse["landuse"].fillna(landuse["leisure"])
centres = gpd.GeoDataFrame(
big, geometry=g.assign(cluster=labels).dissolve("cluster").centroid.loc[big.index], crs=27700)
hit = gpd.sjoin(centres, landuse[["use", "geometry"]], how="left", predicate="within")
hit = hit[~hit.index.duplicated()]
summary = (hit.assign(use=hit.use.fillna("none mapped"),
alone=np.where(hit.subjects == 1, "one subject", "shared"))
.groupby(["use", "alone"]).size())
print(summary.to_string())
use alone
grass shared 1
none mapped one subject 3
shared 4
residential shared 1
retail one subject 1
Why journey endpoints are the weak spot
Why endpoints are the weak spot
The middle of a journey is shared with everyone else on the same road. The ends are not: they are the specific reason the journey happened. Published work on mobility datasets has repeatedly shown that a handful of spatio-temporal points is enough to single out most individuals, and the ends are the most repeated points a person has.
What this particular result does and does not show
The Brighton clusters are small — four endpoints at most. The signal is not the size of a cluster but who made it. Four of the ten shared places were one person returning to the same spot, and a place one person keeps starting and ending at is exactly what the home heuristic looks for. Clustering finds repeated meaningful places; whether they are homes is a question the land-use check has to answer, and here it mostly could not, because 7 of the 10 fell where no land use is mapped. An unanswered check is not a pass.
Why masking points does not fix trajectories
Displacing each fix independently produces a track that zig-zags but still runs along the same road, and the road constrains where the true fixes were. Displacing the whole track by one vector preserves the shape and therefore the origin–destination pair. Neither protects the anchor. The defences that work change what is published, not where the points are.
Why the absence of a user identifier is not protection
If a trace file has no user column, tracks can often be linked back into per-person sets by the anchors themselves — two tracks that share an unusual endpoint are probably the same person. Removing the identifier makes the dataset harder to use and barely harder to attack.
Segment gaps, missing times and urban canyons
- Segment boundaries are not journeys. A GPS that lost signal splits one trip into two.
- Timestamps may be missing or stripped. Without them, dwell detection is impossible and the test is weaker.
- Urban canyons inflate dwell clusters. Multipath makes a stationary receiver wander tens of metres.
- Public anchors are not disclosive. A station, a stadium or a car park is a crowd.
- Mode matters. Cycling and running traces are far more likely to start at home than vehicle fleet data.
- A single trace can still identify. One track from an isolated house is enough.
- Aggregated flows leak too. An origin–destination matrix at building resolution is a trajectory dataset.
- Do this before publishing, not after. The test is only useful while you can still change the release.
Internal links
- Trajectories explained — the data model underneath
- Stops and trips segmentation explained — the dwell detection this borrows
- How to split a GPS track into trips in Python — the segmentation in practice
- Re-identification risk in spatial data explained — why a handful of points is enough
- Geoprivacy explained: why coordinates are personal data — the defences and when each applies
- How to run a privacy check before publishing a spatial dataset — making this a gate
- How to clean a GPS track in Python — removing the noise that inflates dwells
- How to aggregate movement into flows in Python — the usual defence, with its own limits
FAQ
How do you find home locations in GPS data?
Cluster the repeated anchors — track endpoints and long stationary dwells — then take the most visited night-time cluster. The clustering is a few lines; the interpretation needs a land-use layer.
What radius should I cluster at?
25–50 m for building resolution, 100 m for a block. Urban GPS error alone can be tens of metres, so anything tighter finds noise.
Does removing the user identifier protect the traces?
Barely. Tracks can be re-linked into per-person sets by their shared anchors, and a single track from an isolated address is identifying on its own.
Can I geomask a trajectory the way I mask points?
No. Displacing each fix leaves the track running along the same road, and displacing the whole track preserves the origin–destination pair. Truncate the ends or publish aggregated flows instead.
What if the traces have no timestamps?
You can still cluster endpoints, but not detect night-time dwells, so a cluster is a repeated place rather than a probable home. Say so in the report.
Is a cluster of endpoints proof of a home?
No. In the Brighton test most shared places fell where no land use was mapped, so the check could not say. Check every cluster against land use before calling it a residence, and treat "unknown" as unknown.