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, and scikit-mobility >= 1.3 if 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.

PYTHON
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

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.

Back to Spatiotemporal Data Foundations