k-anonymity for trajectory datasets
A k-anonymity floor suppresses any released group representing fewer than k distinct entities. On trajectory data three details decide whether it does anything: the count must be of entities rather than rows, the group has to be the whole released tuple rather than one column, and the suppression is never uniform — it lands almost entirely on rural cells and night-time hours, which is exactly where the data was thin and the risk was highest.
Why this happens
The classical formulation of k-anonymity assumes a table where each individual contributes one row. Trajectory data breaks that assumption immediately: one vehicle contributes thousands of rows to the same cell in a single afternoon. A floor implemented as HAVING count(*) >= 20 is satisfied by a single device that reported twenty times, and it protects nobody.
The second break is subtler. k-anonymity is a property of the quasi-identifier — the combination of released attributes an adversary could use for linkage. On a trajectory release that combination is usually (cell, time bucket) and often (cell, time bucket, mode) or more. Applying the floor to the cell alone leaves a released tuple that is unique even though the cell is popular. The controls this sits among are set out in movement data privacy and anonymization.
Core pipeline
- Define the quasi-identifier explicitly — the full tuple of released attributes an adversary could link on, not just the spatial cell.
- Count distinct entities per group, using the pseudonymous id after rotation.
- Suppress or generalise groups below k, choosing between the two deliberately rather than always suppressing.
- Report the suppression profile — what fraction was removed, and where, because the loss is never uniform.
Production-ready Python implementation
import pandas as pd
import numpy as np
def apply_k_anonymity(
df: pd.DataFrame,
quasi_identifiers: list[str],
entity_col: str = "pseudo_id",
k: int = 20,
generalise: dict | None = None,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Enforce a k-anonymity floor on distinct entities per quasi-identifier group.
Parameters
----------
quasi_identifiers : list[str]
EVERY released attribute an adversary could link on — typically
[cell, time_bucket] and often more. Applying the floor to a subset
leaves released tuples that are unique.
generalise : dict | None
Optional {column: coarser_column} map. Groups below k are retried at
the coarser level before being suppressed, which preserves far more
rural data than suppression alone.
Returns
-------
(released, suppression_report)
Raises
------
ValueError
On missing columns, an empty frame, or k < 2.
"""
missing = set(quasi_identifiers + [entity_col]) - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {sorted(missing)}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
if k < 2:
raise ValueError("k must be at least 2; k=1 is no control.")
work = df.copy()
# nunique, NOT size. This single choice is the difference between a real
# floor and one that a single chatty device satisfies on its own.
work["_n_entities"] = work.groupby(quasi_identifiers)[entity_col].transform("nunique")
passing = work["_n_entities"] >= k
released = [work[passing]]
failed = work[~passing]
# ── optional generalisation pass ──────────────────────────────────
# Retry the failures at a coarser resolution before giving up. In rural
# areas this recovers most of what a suppression-only rule destroys.
if generalise and len(failed):
coarse_qi = [generalise.get(c, c) for c in quasi_identifiers]
miss = set(coarse_qi) - set(failed.columns)
if miss:
raise ValueError(f"Generalisation targets missing: {sorted(miss)}")
failed = failed.copy()
failed["_n_entities"] = (
failed.groupby(coarse_qi)[entity_col].transform("nunique")
)
recovered = failed[failed["_n_entities"] >= k].copy()
# Overwrite the fine columns with the coarse ones so the released row
# cannot be mistaken for fine-grained data later.
for fine, coarse in zip(quasi_identifiers, coarse_qi):
if fine != coarse:
recovered[fine] = recovered[coarse]
released.append(recovered)
failed = failed[failed["_n_entities"] < k]
out = pd.concat(released, ignore_index=True).drop(columns=["_n_entities"])
if len(failed):
report = (
failed.groupby(quasi_identifiers, observed=True)
.size().rename("suppressed_rows").reset_index()
)
else:
report = pd.DataFrame(columns=quasi_identifiers + ["suppressed_rows"])
out.attrs["suppressed_rows"] = int(len(failed))
out.attrs["suppressed_frac"] = float(len(failed) / len(df))
out.attrs["k"] = k
return out, report
Validation block
def validate_k_floor(released: pd.DataFrame, quasi_identifiers: list[str],
entity_col: str, k: int) -> None:
"""
Re-derive the floor independently of the code that applied it.
A shared helper hides the size-versus-nunique bug from itself; a separate
implementation is the only version of this check worth running.
"""
sizes = released.groupby(quasi_identifiers)[entity_col].nunique()
violations = sizes[sizes < k]
assert violations.empty, (
f"{len(violations)} released groups below k={k}; worst has "
f"{violations.min()} entities — the floor was not applied to the "
"full quasi-identifier"
)
# Suppression should not be so heavy that the release is meaningless,
# nor so light that the floor never bound.
frac = released.attrs.get("suppressed_frac", 0.0)
assert frac < 0.5, f"suppressed {frac:.0%} — generalise before suppressing"
assert frac > 0.0, "nothing suppressed — check that k is binding at all"
print(f"OK — k={k} holds across {len(sizes)} groups, {frac:.1%} suppressed")
Common mistakes and gotchas
-
Counting rows instead of entities. The defining error.
nunique, neversize. -
Applying the floor to one column. k on the cell alone leaves (cell, hour, mode) tuples that are unique. The floor must be on the full released tuple.
-
Suppressing before generalising. Retrying failures at a coarser cell or a wider bucket recovers most rural data at a fraction of the utility cost of dropping it.
-
Counting entities before pseudonym rotation. If the id rotates per period, the count must use the rotated id, or groups that look large across periods are small within one.
-
Treating k as a guarantee. k-anonymity bounds linkage against the released attributes only. An adversary with auxiliary data — a known home address, one observed sighting — is not bounded by it, which is why it belongs alongside endpoint truncation rather than instead of it.
-
Not publishing the suppression profile. A release with structural holes that are not documented will be analysed as though it were complete.
FAQ
How do I choose k?
From the release model rather than a standard. Open publication typically wants 20–100 combined with coarse generalisation; a controlled release to a named partner under contract can defensibly use 5–10 with finer cells. The number matters less than the pairing: a small k with fine cells and a large k with coarse cells can carry the same risk and very different utility.
Does k-anonymity work on trajectories at all?
Partially, and its limits are well documented. Applied to independent aggregate cells it is meaningful. Applied to whole trajectories it is close to unachievable, because a trajectory is a sequence and the number of possible sequences dwarfs any realistic population — which is why practical releases aggregate first and apply the floor to the aggregates.
What about l-diversity or t-closeness?
They address a different weakness — a group of k entities that all share the same sensitive value. For movement data the sensitive attribute is usually the location itself, so the more effective additions are the ones on this site’s parent page: endpoint truncation, identifier rotation, and simply not releasing fix-level data.
Related
- Movement Data Privacy & Anonymization — the parent reference and the full control taxonomy.
- Truncating Home Locations with Spatial Cloaking — the control that does the most work before this one applies.
- Discrete Global Grid Systems — the cells the quasi-identifier is built from.
- Origin-Destination Flow Matrices — the most commonly released aggregate and the one that needs this floor.
- Trajectory Object Design Patterns — the entity identifier this control counts.