To apply the Laplace mechanism column-wise, add noise drawn from Laplace(0, sensitivity/epsilon) to each numeric column while preserving NaNs and original dtypes. For numeric columns with real-valued domain and sensitivity s (for a single release of the value, s is typically the range or max change; if values are unbounded use clipped range), noise scale = s/epsilon.python
import numpy as np
import pandas as pd
from copy import deepcopy
def laplace_release(dataset, numeric_columns, epsilon, clip_range=None, random_state=None):
"""
Returns an anonymized copy of dataset with Laplace noise added to numeric_columns.
- dataset: pandas DataFrame
- numeric_columns: list of column names to privatize
- epsilon: privacy budget (float > 0)
- clip_range: dict {col: (min,max)} or single tuple applied to all; if None, use observed min/max
- random_state: seed
"""
if epsilon <= 0:
raise ValueError("epsilon must be > 0")
rng = np.random.default_rng(random_state)
out = dataset.copy(deep=True)
for col in numeric_columns:
if col not in out.columns:
raise KeyError(f"{col} not in dataset")
orig = out[col]
is_na = orig.isna()
# Determine clipping bounds
if clip_range is None:
col_min, col_max = orig.min(skipna=True), orig.max(skipna=True)
elif isinstance(clip_range, dict):
col_min, col_max = clip_range.get(col, (orig.min(skipna=True), orig.max(skipna=True)))
else:
col_min, col_max = clip_range
# If all missing or constant, skip noise addition but preserve dtype
if pd.isna(col_min) or pd.isna(col_max) or col_min == col_max:
out[col] = orig.astype(orig.dtype)
continue
# Sensitivity: assume 1 record change can change value by at most (col_max - col_min)
sensitivity = float(col_max - col_min)
scale = sensitivity / float(epsilon)
# Clip values to bounds before adding noise
clipped = orig.clip(lower=col_min, upper=col_max)
# Generate noise only for non-missing entries
noise = rng.laplace(0.0, scale, size=clipped.shape)
noisy = clipped.astype(float) + noise
# Restore missing values
noisy[is_na.values] = np.nan
# Cast back to original dtype where safe (if integer, round)
if pd.api.types.is_integer_dtype(orig.dtype):
# keep NaNs, so use pandas nullable Int if available
noisy_rounded = np.round(noisy).astype("Int64")
out[col] = noisy_rounded
else:
out[col] = noisy.astype(orig.dtype)
return out
Key points:- Clip input to a known range to bound sensitivity; sensitivity = range.- Preserve NaNs and attempt to preserve integer/float dtypes (uses pandas nullable Int64).- Complexity: O(n * m) for n rows and m numeric columns; space O(n * m) for copy.Empirical evaluation (mean & variance preservation):- Run multiple (e.g., 1000) independent releases, compute per-column sample mean and variance for each run.- Compute bias = average(noisy_mean) - true_mean, and variance of noisy_means; similarly for variance estimates.- Report RMSE, relative error, and 95% confidence intervals.- Visual checks: overlay original vs averaged noisy distributions (histograms / KDE), and compute KL-divergence or Wasserstein distance.- Sensitivity analysis: vary epsilon and clipping ranges, plot error vs epsilon to ensure expected trade-off (error ∝ 1/epsilon).