Applying Laplace Noise to Latitude/Longitude Pairs
Add independent, zero-mean Laplace noise to each coordinate axis — scaled by your spatial sensitivity Δf and privacy budget (ε) — then correct longitude scaling for latitude and wrap the output at ±180°.
Core Calculation permalink
The Laplace mechanism draws perturbations from the distribution with probability density:
where b is the scale parameter. For coordinate data under differential privacy for location data:
Δf is the global L1 sensitivity of the coordinate function — the maximum displacement (in degrees) that adding or removing one record from the dataset can produce.
Converting metres to degrees permalink
WGS84 (EPSG:4326) coordinates are not metric. The conversion is:
| Axis | Metres per degree | Notes |
|---|---|---|
| Latitude | 111,320 m | Uniform globally |
| Longitude | 111,320 × cos(φ) m | Shrinks toward the poles |
where is the maximum allowable displacement in metres and is the latitude of the record being perturbed.
Worked numeric example permalink
- Dataset: taxi drop-off points, centred near 48.8° N (Paris)
- Maximum allowable displacement: 500 m
- Privacy budget: ε = 0.5
The expected absolute displacement on each axis is b (the mean of |Laplace(0, b)|), so both axes inject ~1 km expected error — matching the 500 m sensitivity at ε = 0.5 as expected.
Diagram: coordinate perturbation geometry permalink
The SVG below illustrates how an original point P is displaced by independent Laplace draws on the latitude and longitude axes, with the cosine correction keeping the metric displacement uniform regardless of latitude.
Python Implementation permalink
The function below vectorises noise generation over entire pandas.Series objects, enforces WGS84 bounds, and handles the antimeridian wrap. epsilon maps directly to the privacy budget (ε) for spatial queries; lower values mean more noise and stronger privacy.
import numpy as np
import pandas as pd
from typing import Tuple
# 1° latitude = 111,320 m (WGS84 semi-major axis approximation)
METERS_PER_DEGREE_LAT = 111_320.0
def apply_laplace_noise_to_coords(
lat: pd.Series,
lon: pd.Series,
epsilon: float,
max_displacement_meters: float = 500.0,
seed: int | None = None,
) -> Tuple[pd.Series, pd.Series]:
"""
Perturb WGS84 (EPSG:4326) latitude/longitude pairs with independent
Laplace draws on each axis, calibrated to a metric sensitivity budget.
Privacy guarantee: each output coordinate pair is drawn from a mechanism
that satisfies epsilon-differential privacy with global L1 sensitivity
equal to max_displacement_meters (converted to degrees per axis).
Args:
lat: Latitude values, decimal degrees, range [-90, 90].
lon: Longitude values, decimal degrees, range [-180, 180].
epsilon: Privacy budget ε > 0. Smaller → stronger privacy, more noise.
max_displacement_meters: Global L1 sensitivity Δf in metres.
seed: Optional RNG seed for reproducible tests.
Returns:
Tuple of (noisy_lat, noisy_lon) as pandas Series, same index as input.
Raises:
ValueError: If epsilon ≤ 0 or coordinate ranges are invalid.
"""
if epsilon <= 0:
raise ValueError(f"epsilon must be > 0, got {epsilon!r}")
if lat.min() < -90 or lat.max() > 90:
raise ValueError("Latitude values must be within [-90, 90].")
if lon.min() < -180 or lon.max() > 180:
raise ValueError("Longitude values must be within [-180, 180].")
rng = np.random.default_rng(seed)
# --- Sensitivity in degree-space ---
# Latitude: 1° ≈ 111,320 m worldwide
delta_lat = max_displacement_meters / METERS_PER_DEGREE_LAT
# Longitude: shrinks toward the poles → per-record cosine correction.
# Clip to ±89.99° before cos() to prevent division by zero at poles.
safe_lat = lat.clip(-89.99, 89.99)
cos_lat = np.cos(np.radians(safe_lat))
delta_lon = delta_lat / cos_lat # per-record longitude sensitivity (degrees)
# --- Draw independent Laplace perturbations ---
# scale = Δf / ε (the standard Laplace mechanism calibration)
noise_lat = rng.laplace(loc=0.0, scale=delta_lat / epsilon, size=len(lat))
noise_lon = rng.laplace(loc=0.0, scale=(delta_lon / epsilon).to_numpy(), size=len(lon))
# --- Apply and constrain ---
noisy_lat = (lat + noise_lat).clip(-90.0, 90.0)
# Wrap longitude at the antimeridian (±180°) to avoid coordinate jumps
# in datasets that span the International Date Line
noisy_lon = (lon + noise_lon + 180.0) % 360.0 - 180.0
return noisy_lat.rename(lat.name), noisy_lon.rename(lon.name)
Key decisions in the implementation:
- Per-record cosine correction:
delta_lonis recomputed for every row, so the metric displacement is uniform across latitude bands — critical for datasets spanning more than a few degrees of latitude. - Polar guard: clipping to ±89.99° before
cos()keepsdelta_lonfinite; at true 90° the cosine would be zero, inflating the scale to infinity. - Antimeridian wrap:
(lon + noise + 180) % 360 - 180correctly folds coordinates that stray past ±180° rather than leaving them as invalid values like 182° or −195°. - Vectorisation: operating on
numpyarrays viapandas.Seriesarithmetic avoids Python loops; datasets of 10 M rows complete in under a second on a modern CPU.
Verification permalink
Run this snippet immediately after calling apply_laplace_noise_to_coords to confirm the implementation is behaving as expected:
import numpy as np
import pandas as pd
def verify_laplace_perturbation(
original_lat: pd.Series,
original_lon: pd.Series,
noisy_lat: pd.Series,
noisy_lon: pd.Series,
epsilon: float,
max_displacement_meters: float,
tolerance_factor: float = 10.0,
) -> dict:
"""
Verifies that empirical displacement statistics are consistent with the
theoretical Laplace mechanism calibration.
Returns a dict of check results; raises AssertionError if any check fails.
"""
METERS_PER_DEG = 111_320.0
# Compute per-point metric displacement using the flat-Earth approximation
dlat_m = (noisy_lat - original_lat) * METERS_PER_DEG
cos_lat = np.cos(np.radians(original_lat.clip(-89.99, 89.99)))
dlon_m = (noisy_lon - original_lon) * METERS_PER_DEG * cos_lat
displacement_m = np.sqrt(dlat_m**2 + dlon_m**2)
theoretical_mean_per_axis = max_displacement_meters / epsilon # E[|Lap(0, b)|] = b
empirical_mean = displacement_m.mean()
expected_2d_mean = theoretical_mean_per_axis * np.sqrt(np.pi / 2) # Rayleigh approximation
# Check: coordinate bounds
assert noisy_lat.between(-90, 90).all(), "Latitude out of [-90, 90]"
assert noisy_lon.between(-180, 180).all(), "Longitude out of [-180, 180]"
# Check: empirical mean within tolerance_factor × expected (Monte Carlo sanity)
ratio = empirical_mean / expected_2d_mean
assert 1 / tolerance_factor < ratio < tolerance_factor, (
f"Empirical mean displacement {empirical_mean:.1f} m deviates from "
f"expected {expected_2d_mean:.1f} m (ratio={ratio:.2f})"
)
return {
"empirical_mean_displacement_m": round(empirical_mean, 2),
"expected_2d_mean_m": round(expected_2d_mean, 2),
"ratio": round(ratio, 3),
"lat_in_bounds": True,
"lon_in_bounds": True,
}
# --- Usage ---
rng = np.random.default_rng(42)
n = 100_000
lat = pd.Series(rng.uniform(45.0, 55.0, n), name="lat")
lon = pd.Series(rng.uniform(2.0, 15.0, n), name="lon")
noisy_lat, noisy_lon = apply_laplace_noise_to_coords(
lat, lon, epsilon=0.5, max_displacement_meters=500.0, seed=1
)
results = verify_laplace_perturbation(lat, lon, noisy_lat, noisy_lon, 0.5, 500.0)
print(results)
# Expected output (values will be approximate):
# {'empirical_mean_displacement_m': ~1253.0, 'expected_2d_mean_m': ~1253.3, 'ratio': ~1.000, ...}
The verification compares the empirical mean 2D displacement against the Rayleigh-distributed expectation that arises when both axes draw from independent zero-mean Laplace distributions.
Edge Cases & Adjustments permalink
-
High-latitude datasets (above ±60°): the cosine correction amplifies
delta_lonsignificantly. For polar monitoring data, project to a polar stereographic CRS (e.g., EPSG:3995 for the Arctic) before perturbation, apply uniform metric noise, then inverse-project. This removes the cosine term entirely and keeps displacement isotropic. -
Cross-antimeridian extents: datasets whose bounding box straddles ±180° (Pacific island chains, circumpolar data) require careful validation of the modulo wrap. After perturbation, check that point clusters remain on the correct side; consider shifting the longitude origin to 0–360° before perturbation if the dataset is symmetric around 180°.
-
Small or sparse datasets: the Laplace mechanism’s privacy guarantee is independent of dataset size, but utility collapses quickly for small n. If you have fewer than ~200 points in a region, the expected displacement may exceed the meaningful spatial resolution of the data. Consider k-anonymity grouping for location traces as an alternative or complement, especially when re-identification risk assessment for geospatial datasets flags low crowd density.
-
Composed releases: if you publish the same underlying dataset at multiple spatial resolutions or time windows, basic composition adds the ε values. For a dataset published at three temporal slices each with ε = 0.3, total exposure is ε = 0.9. Track cumulative budget using zero-concentrated DP accounting to stay within organisational thresholds.
FAQ permalink
Why must longitude sensitivity use a cosine correction?
One degree of longitude spans 111,320 metres only at the equator. At latitude φ it spans 111,320 × cos(φ) metres. Using a fixed equatorial conversion at 52°N would understate the actual degree-displacement by a factor of ~1/cos(52°) ≈ 1.62, injecting far more metric noise than intended. Computing delta_lon = delta_lat / cos(lat) per-record ensures that the metric displacement is the same on both axes regardless of where in the world the point sits.
Does applying Laplace noise to coordinates satisfy ε-differential privacy?
Yes — provided Δf genuinely upper-bounds the maximum L1 displacement any single record can introduce. The mechanism outputs a draw from Laplace(true_value, Δf/ε), and the ratio of output probabilities for neighbouring datasets is bounded by exp(ε). The guarantee is over the distribution of outputs, not individual coordinate values; any single perturbed point may land close to or far from its original position.
What happens to the privacy guarantee when the same dataset is published twice?
Sequential applications of the Laplace mechanism compose additively under basic composition: two separate ε-DP releases from the same dataset yield at most 2ε-DP. For tighter accounting across many queries — for example when setting epsilon values for spatial heatmap generation requires multiple resolution tiers — use Rényi DP or zero-concentrated DP accounting, which can reduce the cumulative bound substantially.
Should I perturb individual points or aggregate counts?
For heatmaps and density surfaces, perturb the cell counts after binning rather than the individual points. Point-level Laplace noise is independent per record, so the noise floor grows as in aggregate; count-level noise is per cell. The accuracy vs. utility tradeoffs in geospatial DP page quantifies this difference and shows when point-level perturbation remains acceptable.
Related
- Laplace & Gaussian Noise for Coordinate Data — parent page comparing both mechanisms and when to use each
- Privacy Budget Allocation for Spatial Queries — choosing and tracking ε across a multi-query pipeline
- Setting Epsilon Values for Spatial Heatmap Generation — count-level noise calibration for heatmaps
- Accuracy vs. Utility Tradeoffs in Geospatial DP — quantifying how much spatial utility survives Laplace perturbation
- Re-identification Risk Assessment for Geospatial Datasets — evaluating residual risk after perturbation