Truncating home locations with spatial cloaking

Trip endpoints carry most of a trajectory’s identifying power, because they repeat daily and they are somebody’s address. Removing the first and last few hundred metres of every trip is close to free for corridor and flow analysis and removes the most direct route from a trace to a person. The refinement that matters is making the truncation distance depend on local density rather than being a constant, because a fixed 500 m protects a city block and reveals a farmhouse.

Why this happens

A daily commute produces the same two locations at the same two times, five days a week. That pair is close to unique at city scale, and one end of it is where somebody sleeps. No amount of pseudonym rotation helps, because the identifying information is in the geometry rather than the identifier — rotate the id and the same home-work pair reappears under a new name.

Truncation attacks the geometry directly. It also degrades gracefully: corridor volumes, flow matrices between districts and mode share all survive it almost intact, because they are computed from the middle of trips. What it destroys is first-and-last-mile analysis, which is precisely the analysis that requires knowing where people start. That is not a flaw in the method; it is the trade being made, and the honest thing is to state it. The full control set is in movement data privacy and anonymization.

Core pipeline

  1. Compute cumulative distance along each trip in a projected metric CRS, so the truncation distance means metres.
  2. Size the truncation adaptively — a fixed distance in the city, a larger one where local density is low.
  3. Drop trips too short to survive, rather than truncating them partially and leaving endpoints that are still endpoints.
  4. Verify against a home-detection attack, because the only meaningful test is whether the home can still be found.

Production-ready Python implementation

PYTHON
import numpy as np
import pandas as pd


def truncate_trip_ends(
    df: pd.DataFrame,
    density: pd.Series | None = None,
    trip_col: str = "trip_id",
    dist_col: str = "cum_dist_m",
    base_m: float = 500.0,
    min_retained_m: float = 500.0,
    max_m: float = 3000.0,
) -> pd.DataFrame:
    """
    Remove the first and last portion of every trip, sized by local density.

    Parameters
    ----------
    df : pd.DataFrame
        Fix-level frame with a trip id and cumulative along-trip distance in
        METRES. Computing cum_dist in degrees produces a truncation whose
        real length varies with latitude.
    density : pd.Series | None
        Optional entities-per-km² at each trip's origin cell, indexed by
        trip id. Where it is low the cloaking radius is widened, because a
        fixed 500 m that anonymises a city block names a farmhouse.
    min_retained_m : float
        A trip must retain at least this much after truncation or it is
        dropped entirely. Partial truncation leaves endpoints that are still
        effectively the origin and destination.

    Returns
    -------
    pd.DataFrame
        The retained middles, with 'cloak_m' recording what was applied.

    Raises
    ------
    ValueError
        On missing columns, an empty frame, or non-monotone distance.
    """
    required = {trip_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.")

    out = df.sort_values([trip_col, dist_col]).copy()
    if (out.groupby(trip_col)[dist_col].diff().fillna(0) < 0).any():
        raise ValueError(
            "cum_dist_m decreases within a trip — recompute it from the "
            "sorted fixes; a non-monotone distance makes truncation arbitrary."
        )

    trip_len = out.groupby(trip_col)[dist_col].transform("max")

    # ── adaptive cloaking radius ──────────────────────────────────────
    # Scale inversely with the square root of density, so the cloaked area
    # contains a roughly constant number of candidate addresses.
    if density is not None:
        d = density.reindex(out[trip_col]).to_numpy()
        d = np.where(np.isfinite(d) & (d > 0), d, np.nan)
        ref = np.nanmedian(d)
        scale = np.sqrt(ref / d)
        cloak = np.clip(base_m * np.nan_to_num(scale, nan=1.0), base_m, max_m)
    else:
        cloak = np.full(len(out), base_m)
    out["cloak_m"] = cloak

    keep = (out[dist_col] > out["cloak_m"]) & (
        out[dist_col] < trip_len - out["cloak_m"]
    )
    long_enough = trip_len - 2 * out["cloak_m"] >= min_retained_m

    kept = out[keep & long_enough].copy()
    dropped_trips = out.loc[~long_enough, trip_col].nunique()
    kept.attrs["dropped_trips"] = int(dropped_trips)
    kept.attrs["retained_frac"] = float(len(kept) / len(df))
    return kept

Validation block

PYTHON
def validate_cloaking(original: pd.DataFrame, cloaked: pd.DataFrame,
                      home_truth: pd.Series, trip_col: str = "trip_id",
                      entity_col: str = "pseudo_id", radius_m: float = 200.0) -> dict:
    """
    Attack the cloaked data with the detector the cloaking is meant to defeat.

    home_truth : known home coordinates per entity, held back for this test.
    A match rate near zero is the only evidence the control worked; the
    truncation distance alone proves nothing.
    """
    from scipy.spatial import cKDTree

    # Naive home detection: the most common first-fix location per entity.
    first_fixes = (cloaked.sort_values("cum_dist_m")
                          .groupby([entity_col, trip_col]).first()
                          .groupby(level=0)[["x", "y"]].median())
    truth = home_truth.reindex(first_fixes.index).dropna()
    if truth.empty:
        raise ValueError("No overlap between the attack set and the release.")

    tree = cKDTree(np.column_stack(truth.tolist()))
    d, _ = tree.query(first_fixes.loc[truth.index].to_numpy(), k=1)
    hit_rate = float((d < radius_m).mean())

    assert hit_rate < 0.05, (
        f"{hit_rate:.0%} of homes still recoverable within {radius_m} m — "
        "increase the cloaking radius or check that truncation ran before "
        "any downstream re-derivation of trip ends"
    )
    return {"home_hit_rate": hit_rate,
            "retained_frac": cloaked.attrs.get("retained_frac"),
            "dropped_trips": cloaked.attrs.get("dropped_trips")}

Common mistakes and gotchas

  • Truncating by fix count instead of distance. Ten fixes is 10 m in a queue and 400 m on a motorway. Use cumulative distance, always.

  • Partially truncating short trips. A 900 m trip truncated by 500 m at each end retains nothing useful and its remaining points are still adjacent to both ends. Drop such trips and count them.

  • Re-deriving trip ends downstream. If a consumer runs stay-point detection on the released data, it finds the new first and last fixes and calls them origins. The released schema should mark trips as truncated so that is at least visible.

  • Using a single radius everywhere. The urban setting is meaningless in the countryside and the rural setting destroys urban utility. Adaptive sizing costs one join against a density layer.

  • Cloaking after aggregation. By then the fine-grained endpoints are already baked into the aggregate. Truncate first, aggregate second — the ordering is the whole point of the pipeline in the parent page.

  • Never running the attack. The truncation distance is an input, not evidence. Only a home-detection attempt against held-back ground truth tells you whether it worked.

FAQ

How much distance should I remove?

Start at 500 m or three minutes, whichever is longer, and scale it up where density is low. The three-minute floor matters because a vehicle crawling out of a car park covers very little ground in the period that most reveals where it started.

Does this break origin-destination analysis?

It breaks fine-grained OD analysis and leaves district-level OD almost intact, because the truncated portion is usually inside the origin zone anyway. If your zones are small enough that 500 m crosses several of them, the honest answer is that this release cannot support that analysis — see origin-destination flow matrices for the zoning trade-off.

Is cloaking better than adding noise?

For endpoints, yes. Additive noise of a magnitude large enough to hide a home also destroys the corridor geometry it passes through, and repeated trips let an adversary average the noise away. Truncation removes the information rather than perturbing it, which is not reversible by repetition.

Back to Movement Data Privacy & Anonymization