Movement Data Privacy & Anonymization
Movement data privacy is the practice of reducing the re-identification risk carried by trajectory data to a level appropriate for how the data will be released, and of measuring what that reduction costs the analysis it was collected for.
It belongs in the foundations section rather than as an afterthought because the decisive choices are schema choices. Whether endpoints are truncated, how coarse the spatial key is, and whether pseudonyms are stable across months are all decided when the archive is designed, and all of them are painful to retrofit once years of data exist and downstream consumers depend on the current shape.
Prerequisites
- A clear release model. Internal analysis, controlled release to a named partner, and open publication are three different problems with three different answers. Everything below depends on which one you are in.
- Legal grounding. This page is an engineering guide, not legal advice; jurisdictions differ substantially in whether pseudonymised trajectory data is personal data, and that determination has to be made before the pipeline is designed.
- A settled schema. See trajectory object design patterns; the quality bitfield and identifier column are the ones that matter here.
- Python stack.
pandas >= 2.0,geopandas >= 0.14,h3-py >= 3.7, andscikit-mobility >= 1.3if you want its privacy-risk assessment tooling.
Why Trajectories Are Identifying
The intuition that stripping the user id anonymises a movement dataset is wrong in a way that is easy to demonstrate and hard to un-see. Human mobility is highly regular and highly individual: most people sleep in one place and work in another, and the pair of those two locations is close to unique at city scale. Add a third regular location and the pair becomes unique almost everywhere.
Two consequences follow. First, “anonymised” is not a property a dataset has; it is a statement about a release context, an assumed adversary and an accepted residual risk. Second, because uniqueness falls only as a power law in resolution, generalisation has to be combined with other controls — endpoint removal, group-size floors, identifier rotation — rather than relied on alone.
Control Taxonomy
| Control | What it does | Utility cost | Fails when |
|---|---|---|---|
| Endpoint truncation | drops the first/last part of each trip | low for corridors, high for first-mile | trips are short enough that little remains |
| Spatial generalisation | snaps positions to a grid cell | moderate; hurts junction-level work | cells are sparse in rural areas |
| Temporal generalisation | snaps timestamps to a bucket | low for daily patterns | analysis needs sub-bucket ordering |
| k-anonymity floor | suppresses groups smaller than k | concentrated in exactly the sparse cases | k counts rows instead of entities |
| Identifier rotation | re-keys pseudonyms each period | breaks longitudinal analysis | rotation period exceeds the linkage window |
| Sampling | releases a subset of entities | proportional, and unbiased | the sample is drawn non-uniformly |
| Aggregation only | never releases fix-level data | high for anything individual | successive releases can be differenced |
Endpoint truncation is the highest value-for-cost control on the list and the one most often skipped. The start and end of a journey are where home and workplace are, they repeat every weekday, and they are exactly what an adversary needs. Removing the first and last few hundred metres of every trip is close to free for corridor volumes, flow matrices and mode share, and it removes the most direct path from a trajectory to an address.
Deterministic Pipeline Overview
Implementation Walkthrough
The function below applies endpoint truncation and generalisation, then enforces the group-size floor on distinct entities.
import hashlib
import pandas as pd
import numpy as np
def anonymize_trips(
df: pd.DataFrame,
salt: bytes,
truncate_m: float = 500.0,
time_bucket: str = "15min",
k: int = 20,
entity_col: str = "entity_id",
trip_col: str = "trip_id",
cell_col: str = "h3_r8",
time_col: str = "t",
dist_col: str = "cum_dist_m",
) -> pd.DataFrame:
"""
Apply endpoint truncation, temporal generalisation and a k-anonymity floor.
Assumes positions are ALREADY generalised to cell_col and that dist_col
holds cumulative distance along each trip in metres.
Parameters
----------
salt : bytes
Rotated per release period. Reusing a salt across periods lets an
adversary link pseudonyms into a long history — which is the thing
rotation exists to prevent.
k : int
Minimum number of DISTINCT entities per (cell, time bucket).
Returns
-------
pd.DataFrame
Truncated, generalised, suppressed frame with a rotated pseudonym.
Raises
------
ValueError
On missing columns, an empty frame, or a non-positive k.
"""
required = {entity_col, trip_col, cell_col, time_col, dist_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
if k < 1:
raise ValueError("k must be at least 1.")
out = df.copy()
# ── 1. Rotated pseudonym ──────────────────────────────────────────
def _pseudo(v: str) -> str:
return hashlib.blake2b(str(v).encode(), key=salt, digest_size=12).hexdigest()
out["pseudo_id"] = out[entity_col].map(_pseudo)
out = out.drop(columns=[entity_col])
# ── 2. Endpoint truncation ────────────────────────────────────────
# Drop the first and last truncate_m of each trip. A trip too short to
# survive is dropped entirely rather than partially truncated: keeping
# its middle would leave endpoints that are still endpoints.
grp = out.groupby(trip_col)[dist_col]
trip_len = grp.transform("max")
keep = (out[dist_col] > truncate_m) & (out[dist_col] < trip_len - truncate_m)
out = out[keep & (trip_len > 3 * truncate_m)].copy()
if out.empty:
raise ValueError(
f"No trip survived truncation at {truncate_m} m — "
"lower truncate_m or accept that this dataset cannot be released."
)
# ── 3. Temporal generalisation ────────────────────────────────────
out["t_bucket"] = out[time_col].dt.floor(time_bucket)
out = out.drop(columns=[time_col])
# ── 4. k-anonymity floor on DISTINCT ENTITIES ─────────────────────
sizes = (
out.groupby([cell_col, "t_bucket"])["pseudo_id"]
.transform("nunique") # nunique, not size — the whole point
)
suppressed = out[sizes < k]
out = out[sizes >= k].copy()
out.attrs["suppressed_rows"] = int(len(suppressed))
out.attrs["suppressed_frac"] = float(len(suppressed) / max(len(df), 1))
return out
Three lines carry most of the value. transform("nunique") rather than transform("size") is the difference between a real k-anonymity floor and one that a single chatty device satisfies on its own. Dropping trips shorter than three times the truncation distance prevents the case where truncation leaves a two-point “middle” whose endpoints are still effectively the origin and destination. And recording the suppressed fraction in attrs makes the cost visible: a release that suppressed 40% of its rows is a different product from one that suppressed 2%, and the difference is invariably concentrated in rural areas and at night.
Calibration and Parameter Tuning
| Parameter | Open release | Controlled release | Notes |
|---|---|---|---|
| Endpoint truncation | 500–1000 m | 200–500 m | Or the first/last 3–5 minutes, whichever is longer |
| Spatial cell | H3 res 7–8 | H3 res 9 | Coarser in low-density areas, never finer than positional error |
| Time bucket | 30–60 min | 5–15 min | Coarser at night, when counts are thin |
| k | 20–100 | 5–10 | On distinct entities, always |
| Pseudonym rotation | daily | monthly | Shorter than the linkage window you are defending against |
The pattern in that table is that every control tightens as the audience widens, and that low-density areas and night-time hours need different settings from the busy centre at rush hour. A single global parameter set produces either an over-suppressed city centre or an under-protected rural fringe; adaptive cell sizing — coarsening the grid where counts are thin — is the standard resolution and is worth the extra complexity.
Integration and Compatibility
The controls interact with the rest of the pipeline in ways worth planning for. Endpoint truncation removes exactly the fixes that stay-point detection uses to find trip ends, so a downstream consumer of anonymised data cannot rebuild the trips it was given. That is intentional, and it means the trip structure has to be computed before anonymisation and carried through as an attribute rather than re-derived after.
Spatial generalisation interacts with discrete global grid systems directly — the privacy cell and the analysis cell should be the same cell, or the analysis will re-introduce precision the privacy step removed by interpolating between them. And identifier rotation breaks any longitudinal question by design, which is why the rotation period is a product decision rather than a security parameter: it defines the longest history anybody, including your own analysts, will ever be able to assemble.
Finally, retention. The tiering discussed in spatiotemporal data foundations is a privacy control as much as a cost control: raw fix-level data that no longer exists cannot be re-identified, and a demotion schedule is far easier to operate than a deletion policy that depends on somebody remembering.
Validation and Testing Patterns
Privacy controls are unusual in that a bug makes the output look better rather than worse — a suppression rule that never fires produces a richer dataset, and nothing in the pipeline complains. That asymmetry means the tests have to assert on the controls directly.
Adversarial re-identification. Hold back a small set of known trajectories, apply the pipeline, and attempt to match them back using only the released fields plus plausible auxiliary data — a home address, a workplace, a single observed sighting. Record the match rate. This is the only measurement that answers the question the release is actually about, and it is worth running before every publication rather than once at design time.
Uniqueness sweep. Compute the share of entities uniquely identified by two, three and four released points at the release resolution. If the curve looks like the raw one, the generalisation is not doing what you assumed.
Suppression audit. Assert that no released group has fewer than k distinct entities, computed independently of the code that did the suppressing. A separate implementation catches the size versus nunique class of bug that a shared helper hides.
Utility regression. Run the two or three analyses the data exists for on both the raw and released versions and record the difference. Publishing that number alongside the dataset is what turns a privacy decision into an engineering trade-off that a reader can evaluate rather than a claim they have to accept.
In This Section
- k-anonymity for trajectory datasets — implementing the group-size floor over cells and sequences, and the sparse-cell problem it creates.
- Truncating home locations with spatial cloaking — endpoint removal and adaptive cloaking radii in low-density areas.
FAQ
Is removing the user id enough to anonymise trajectory data?
No, and this is the most consequential misconception in mobility data. Human movement is highly distinctive: published work on mobile-phone traces found four approximate spatiotemporal points sufficient to uniquely identify around 95% of individuals in a dataset of a million people. A trajectory with no name attached still contains where somebody sleeps, works and visits.
What is the single most effective anonymisation step?
Truncating trip endpoints. The start and end of a journey carry most of the identifying power because they are home and work, and they repeat daily. Removing the first and last few hundred metres costs very little for corridor and flow analysis and removes the most direct route from a trajectory to an address.
How large does k need to be?
It depends on the release. For public open data, 20–100 combined with coarse generalisation is common. For a controlled release to a named partner under contract, 5–10 with tighter cells is often defensible. The discipline that matters is that k applies to distinct entities, not rows: a thousand fixes from one vehicle is a group of one.
Does aggregating to a grid make data safe to publish?
Not on its own. Sparse cells leak directly — three trips at 03:00 in a rural cell effectively names them — and successive aggregates over the same population can be differenced to recover individual movement. Aggregation needs a minimum-count suppression rule, and any repeated release needs to consider what differencing reveals.
How much utility does anonymisation cost?
Much less than teams fear for aggregate questions and much more than they hope for individual ones. Corridor volumes, mode share and district-level flows survive endpoint truncation and moderate generalisation nearly intact. Trip-length distributions and first-and-last-mile analysis degrade sharply. Measure it: run the target analysis on both versions and report the difference.
Related
- Trajectory Object Design Patterns — the schema decisions, including the identifier column, that this page constrains.
- Discrete Global Grid Systems — the cells used for spatial generalisation.
- Stay-Point Detection Algorithms — what endpoint truncation is designed to defeat.
- Spatial Storage Formats — retention tiering as a privacy control.
- Origin-Destination Flow Matrices — the aggregate product most often released, and the suppression rules it needs.