Skip to content
About
Main content

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. adds noise drawn from a with scale sensitivity / , 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

At ε = 0.5, how large is the Laplace noise's mean error on a true count of 5?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    differencing-attack-and-the-laplace-mechanism.py92 linesDownload
    View 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()
  3. 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
Knobs
Knob · defaultWhat 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

Relative error of a noisy countApple M2 · 8 cores · 8 GB
ε = 0.1ε = 0.5ε = 2.0
Data table
Relative error of a noisy count
True countseriesMean relative error
5ε = 0.1201%
100ε = 0.110.1%
1Mε = 0.10.001%
5ε = 0.540.2%
100ε = 0.52.02%
1Mε = 0.50%
5ε = 2.010%
100ε = 2.00.505%
1Mε = 2.00%
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20 · 3 runs

Noisy counts at ε = 0.5, 1,000 draws each

Noisy counts at ε = 0.5, 1,000 draws each
True countMean absolute errorRelative error
52.0140.2%
1002.022.02%
1M2.020%
Output · uv run differencing-attack-and-the-laplace-mechanism.py --eps 0.5
--- 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.

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.