How to test whether GPS traces reveal where people live
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 33 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: 66 endpoints from 33 segments collapsed to 31 clusters, the largest holding 10, and 50.0% of endpoints fell in a cluster shared by three or more tracks.
Step-by-step solution
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 is why the Brighton result is weaker than it looks.
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.
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.
Code examples
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")
33 segments, 19,905 points
Example 2 โ cluster the endpoints and report
import geopandas as gpd, numpy as np, pandas as pd
from scipy.spatial import cKDTree
rows = []
for name, pts in segments:
rows.append((name, pts[0][0], pts[0][1], "start"))
rows.append((name, pts[-1][0], pts[-1][1], "end"))
ends = pd.DataFrame(rows, columns=["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%}")
66 endpoints -> 31 clusters at 50 m
clusters with 3+ endpoints: 7; largest 10
share of endpoints in a 3+ cluster: 50.0%
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]
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)}")
Cutting 500 m from each end destroys short trips entirely โ check how many, because a release that silently drops all the local journeys has changed what the dataset is about.
Explanation
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.
Why this particular result is weaker than it looks
The Brighton traces are dominated by an organised city race: the ten-endpoint cluster is a start line, not a doorstep. That is the honest reading, and it is the reason step 6 exists. Clustering finds repeated meaningful places; whether they are homes is a question about the dataset, not about the algorithm. Run the same code on commuter traces and the answer changes.
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.
Edge cases or notes
- 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 the largest cluster was a race start line. Check every cluster against land use before calling it a residence.