Differencing attack and the Laplace mechanism
Break it on purposeNeeds Python with NumPy
Problem
Exact aggregates leak individuals: the sum over everyone minus the sum over everyone except one person is that person's value. Differential privacy adds noise drawn from a Laplace distribution with scale sensitivity / epsilon, so the difference between the two answers is hidden by noise. For a count the sensitivity is 1, so the noise has the same size whatever the true count is, which makes it large for small counts and negligible for large ones.
Reconstruct one person's value from exact aggregates, then see noise stop it and measure the error. Take 1,000 salaries, subtract SUM(all except one) from SUM(all), then answer counts of 5, 100 and 1,000,000 a thousand times each with Laplace noise of scale 1/epsilon, and finally clamp salaries at 10,000 before a noisy SUM.
The question. What is the error of a noisy count at epsilon = 0.5, and how does it compare with the true count?
Predict
The mean error is about 2 (the Laplace scale 1/0.5) for every count, so it is negligible for 1,000,000, about 2% for 100 and about 40% for 5. The differencing step without noise returns the exact salary.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
differencing-attack-and-the-laplace-mechanism.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Differencing attack and the Laplace mechanism. Reconstruct one person's value from exact aggregates, then see noise stop it and measure the error.""" import argparse import json import numpy as np def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument("--rows", type=int, default=1000, help="Number of salaries in the dataset.") parser.add_argument("--eps", type=float, default=0.5, help="Epsilon value for differential privacy.") parser.add_argument("--cap", type=int, default=10000, help="Clamp cap for salaries.") parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility.") parser.add_argument("--json", type=str, help="Also write the results to this JSON file.") args = parser.parse_args() rng = np.random.default_rng(args.seed) results = {"summary": {}, "tables": {"laplace_errors": []}} print(f"--- 1. Differencing Attack on Exact Aggregates ({args.rows} rows) ---") salaries = rng.lognormal(mean=10, sigma=1, size=args.rows) target_index = 42 target_salary = salaries[target_index] sum_all = np.sum(salaries) mask = np.ones(args.rows, dtype=bool) mask[target_index] = False sum_except_target = np.sum(salaries[mask]) differenced_salary = sum_all - sum_except_target exact_recovery = bool(abs(target_salary - differenced_salary) < 1e-5) print(f"Target's actual salary: {target_salary:.2f}") print(f"Recovered salary via differencing: {differenced_salary:.2f}") print(f"Exact recovery successful: {exact_recovery}") results["summary"]["target_salary"] = float(target_salary) results["summary"]["differenced_salary"] = float(differenced_salary) results["summary"]["exact_recovery"] = exact_recovery print(f"\n--- 2. The Laplace Mechanism on Counts (eps = {args.eps}) ---") print(f"{'True Count':>12} | {'Mean Abs Error':>15} | {'Relative Error':>15}") print("-" * 50) for true_val in (5, 100, 1_000_000): # Add Laplace noise with scale 1/epsilon for 1000 runs noisy = true_val + rng.laplace(0, 1/args.eps, 1000) err = np.abs(noisy - true_val).mean() rel_err = 100 * err / true_val print(f"{true_val:12} | {round(err, 2):15} | {rel_err:14.5f}%") results["tables"]["laplace_errors"].append({ "true_count": true_val, "mean_abs_error": float(err), "relative_error_pct": float(rel_err) }) print(f"\n--- 3. Clamping and Noisy SUMs (cap = {args.cap}) ---") clamped_salaries = np.clip(salaries, a_min=0, a_max=args.cap) true_sum = np.sum(salaries) clamped_sum = np.sum(clamped_salaries) noisy_clamped_sum = clamped_sum + rng.laplace(0, args.cap/args.eps) distortion = abs(true_sum - clamped_sum) distortion_pct = 100 * distortion / true_sum print(f"True SUM (skewed dataset): {true_sum:,.2f}") print(f"Clamped SUM: {clamped_sum:,.2f}") print(f"Noisy Clamped SUM: {noisy_clamped_sum:,.2f}") print(f"Distortion from clamping: {distortion:,.2f} ({distortion_pct:.2f}%)") results["summary"]["true_sum"] = float(true_sum) results["summary"]["clamped_sum"] = float(clamped_sum) results["summary"]["noisy_clamped_sum"] = float(noisy_clamped_sum) results["summary"]["distortion"] = float(distortion) results["summary"]["distortion_pct"] = float(distortion_pct) if args.json: with open(args.json, "w") as f: json.dump(results, f, indent=2) if __name__ == "__main__": main()3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run differencing-attack-and-the-laplace-mechanism.py uv run differencing-attack-and-the-laplace-mechanism.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows1000 | Number of salaries in the dataset. |
| --eps0.5 | Epsilon value for differential privacy. |
| --cap10,000 | Clamp cap for salaries. |
| --seed42 | Random seed for reproducibility. |
Our run
Data table
| True count | series | Mean relative error |
|---|---|---|
| 5 | ε = 0.1 | 201% |
| 100 | ε = 0.1 | 10.1% |
| 1M | ε = 0.1 | 0.001% |
| 5 | ε = 0.5 | 40.2% |
| 100 | ε = 0.5 | 2.02% |
| 1M | ε = 0.5 | 0% |
| 5 | ε = 2.0 | 10% |
| 100 | ε = 2.0 | 0.505% |
| 1M | ε = 2.0 | 0% |
Noisy counts at ε = 0.5, 1,000 draws each
| True count | Mean absolute error | Relative error |
|---|---|---|
| 5 | 2.01 | 40.2% |
| 100 | 2.02 | 2.02% |
| 1M | 2.02 | 0% |
--- 1. Differencing Attack on Exact Aggregates (1000 rows) ---
Target's actual salary: 11321.86
Recovered salary via differencing: 11321.86
Exact recovery successful: True
--- 2. The Laplace Mechanism on Counts (eps = 0.5) ---
True Count | Mean Abs Error | Relative Error
--------------------------------------------------
5 | 2.01 | 40.16506%
100 | 2.02 | 2.01932%
1000000 | 2.02 | 0.00020%
--- 3. Clamping and Noisy SUMs (cap = 10000) ---
True SUM (skewed dataset): 34,733,071.59
Clamped SUM: 9,141,646.78
Noisy Clamped SUM: 9,136,816.84
Distortion from clamping: 25,591,424.80 (73.68%)What we found
Differencing two exact sums returned the target's salary exactly (11.3K against 11.3K).
With noise at ε = 0.5 the mean absolute error was 2.01 at a true count of 5, 2.02 at 100 and 2.02 at 1,000,000: the same size everywhere, as the chapter expects. As a share of the count that is 40.2%, 2.02% and 0%. Tightening to ε = 0.1 made the error 10; loosening to ε = 2 made it 0.502.
Clamping salaries at 10,000 before the noisy SUM removed 73.7% of the true total in this skewed (lognormal) data, far more than the noise itself.
Read it
Laplace noise is calibrated to how much one person can change the answer, not to the answer, so the same epsilon is harmless on large groups and destructive on small ones. For sums, the sensitivity is the largest value one person can contribute, which is why values must be clamped; on skewed data the clamp, not the noise, is often the larger error, and choosing it is part of the privacy design.