Redacting Sensitive Locations Before Publication
Derive every displacement deterministically from a stable per-feature key, so re-running the pipeline moves each point to the same place it moved to last time — a random jitter re-applied on each release lets a recipient average several releases back to the true location, which is the opposite of what the redaction was for.
Redaction is where good intentions produce the most self-defeating implementations. Jittering points sounds like generalisation and is not; suppressing a category leaves its absence visible; masking a boundary at one resolution while publishing a derived layer at another gives both away. This guide sits under regulatory obligations for location data, within Spatial Data Audit Reporting & Compliance Governance.
Automated Python Implementation
#!/usr/bin/env python3
"""Deterministic, reproducible redaction for point layers."""
import hashlib
import math
import geopandas as gpd
from shapely.geometry import Point
def _unit_offsets(key, secret):
"""Two stable values in [0, 1) derived from a feature key and a held secret."""
digest = hashlib.sha256(f"{secret}:{key}".encode()).digest()
a = int.from_bytes(digest[:8], "big") / 2 ** 64
b = int.from_bytes(digest[8:16], "big") / 2 ** 64
return a, b
def displace_point(point, key, secret, max_radius_m):
"""Move a point to a fixed pseudo-random position within max_radius_m.
Uniform over the disc — sqrt on the radius, or points bunch at the centre
and the displacement is weaker than the stated radius implies.
"""
a, b = _unit_offsets(key, secret)
radius = max_radius_m * math.sqrt(a)
angle = 2 * math.pi * b
return Point(point.x + radius * math.cos(angle),
point.y + radius * math.sin(angle))
def round_to_grid(point, cell_m):
"""Snap to a grid cell centre; the simplest defensible generalisation."""
return Point(math.floor(point.x / cell_m) * cell_m + cell_m / 2,
math.floor(point.y / cell_m) * cell_m + cell_m / 2)
def redact(gdf, key_column, secret, method="round", cell_m=100, max_radius_m=100,
metric_crs="EPSG:3857"):
"""Apply a redaction and record exactly what was done."""
if gdf.crs is None:
raise ValueError("layer has no CRS; metre-based redaction is undefined")
original_crs = gdf.crs
working = gdf.to_crs(metric_crs).copy()
if method == "round":
working["geometry"] = working.geometry.apply(lambda g: round_to_grid(g, cell_m))
parameters = {"method": "round", "cell_m": cell_m}
elif method == "displace":
working["geometry"] = [
displace_point(geom, key, secret, max_radius_m)
for geom, key in zip(working.geometry, working[key_column])
]
parameters = {"method": "displace", "max_radius_m": max_radius_m}
else:
raise ValueError(f"unknown redaction method {method!r}")
result = working.to_crs(original_crs)
result.attrs["redaction"] = parameters
return result, parameters
Three details in that code are the ones that separate a redaction from a gesture.
The offsets come from a hash of a stable key and a held secret. The key makes the displacement reproducible across runs; the secret stops a recipient who knows the algorithm from inverting it. Both are necessary — a hash of the key alone is reversible by anyone with the key list, and a random offset is not reproducible at all.
The radius uses a square root. Sampling radius uniformly puts most points near the centre, so a stated 100 metre displacement delivers a median of about 33 metres. Uniform-over-the-disc sampling delivers what it says.
The method and parameters travel with the output. A redacted layer whose displacement radius is not recorded cannot be interpreted by an analyst or defended by its publisher, and the parameters are exactly what a reviewer asks for.
Validation and Pipeline Integration
def test_displacement_is_reproducible():
point = Point(0, 0)
first = displace_point(point, "feature-1", "secret", 100)
second = displace_point(point, "feature-1", "secret", 100)
assert first.equals(second)
def test_different_features_move_differently():
point = Point(0, 0)
a = displace_point(point, "feature-1", "secret", 100)
b = displace_point(point, "feature-2", "secret", 100)
assert not a.equals(b)
def test_displacement_stays_within_the_radius():
point = Point(0, 0)
for i in range(500):
moved = displace_point(point, f"f{i}", "secret", 100)
assert moved.distance(point) <= 100.0001
def test_redaction_parameters_are_recorded():
gdf = gpd.GeoDataFrame({"id": ["a"]}, geometry=[Point(0, 0)], crs="EPSG:4326")
_, parameters = redact(gdf, "id", "secret", method="round", cell_m=100)
assert parameters == {"method": "round", "cell_m": 100}
Run the redaction as the last step before export, after every join and derivation, and re-measure re-identification risk on the redacted output rather than assuming the redaction worked. A displacement that leaves a rural point isolated has changed its coordinates and not its identifiability, which the measurement in assessing re-identification risk in point datasets will show immediately.
What Redaction Cannot Fix
Some disclosure risks are structural, and applying a stronger displacement to them makes the data worse without making it safer.
Attribute uniqueness survives any geometric change. A record that is the only one of its category in the dataset is identifiable from the attribute alone once anyone knows a single member. Moving its point does nothing. The response is attribute generalisation — collapsing rare categories — or suppression, and it belongs to a different toolbox than displacement.
Relationships between layers leak. Publishing a redacted point layer alongside an unredacted layer derived from the same source is the common version of this: the derived layer’s geometry, counts or extents constrain where the redacted points must be. The fix is to treat all products of one source as a single release and redact them consistently, which requires knowing what those products are — a lineage question.
Repeated releases of a moving population reveal trajectories. A quarterly release of redacted current locations, each displaced consistently, gives a recipient a sequence of positions for the same feature key. Consistency is what makes displacement defensible within a release and what creates a trajectory across them, and the tension is real rather than a mistake in the implementation. Where a population moves, the release schedule is part of the disclosure design, not an operational detail.
Boundaries give away what points do not. A masked point layer published with a service-area polygon computed from the unmasked points hands back the original geometry at analytical precision. Any derived product built before redaction has to be either rebuilt afterwards or withheld.
Long-Term Compliance Best Practices
- Never re-seed between releases. A fresh random offset each time lets averaging recover the true position.
- Keep the secret out of the repository. It belongs with deployment secrets; publishing it makes the displacement invertible.
- Record the method and parameters with the output. They are what an analyst needs and what a reviewer will ask for.
- Redact last, then re-measure. Redaction applied before a join can be undone by the join.
- Prefer rounding when it suffices. It is easier to explain, easier to reason about, and has no secret to protect.
Related
- Regulatory Obligations for Location Data — when redaction is required and what the assessment records
- Assessing Re-identification Risk in Point Datasets — measuring whether the redaction achieved anything
- Documenting Cross-Border Transfer of Spatial Data — the record a transfer of redacted data still needs
- Spatial Data Lineage & Provenance Tracking — finding the derived products a redaction has to cover