Skip to content
About
Main content

A privacy budget ledger

Simulate a modelNeeds Python with NumPy

Problem

Each differentially private answer spends some of a fixed privacy budget, ; answers compose, so the total leaked is the sum of what was spent. A system that enforces the budget must keep a ledger and refuse queries once it is spent, and it must charge a repeated query again, because averaging repeated noisy answers removes the noise.

Feel that a privacy budget is depleted by reading. A wrapper dp_count adds Laplace noise and subtracts epsilon from a budget of 1.0, refusing a query that would take the budget below zero. Run 15 exploratory queries at ε = 0.1, then ask one query 10 times and average the answers.

The question. How many of the 15 queries are answered, and what does averaging the repeated query do?


Predict

Averaging ten noisy answers to the same count (true value 500): how close does the average come?

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.

    a-privacy-budget-ledger.py104 linesDownload
    View the code
    a-privacy-budget-ledger.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """A privacy budget ledger.
    
    Feel that a privacy budget is depleted by reading by simulating queries against a fixed budget."""
    
    import argparse
    import json
    import numpy as np
    
    class PrivacyLedger:
        def __init__(self, initial_budget, rng):
            self.budget = initial_budget
            self.rng = rng
    
        def dp_count(self, true_count, eps):
            # Check if budget is depleted (with small tolerance for float math)
            if self.budget - eps < -1e-9:
                return "REFUSED: Privacy budget depleted"
            
            self.budget -= eps
            noisy_count = true_count + self.rng.laplace(0, 1/eps)
            return noisy_count
    
    def main():
        parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
        parser.add_argument("--budget", type=float, default=1.0, help="Initial global privacy budget.")
        parser.add_argument("--eps", type=float, default=0.1, help="Privacy budget consumed per query.")
        parser.add_argument("--queries", type=int, default=15, help="Number of exploratory queries to run.")
        parser.add_argument("--repeats", type=int, default=10, help="Number of repeated queries to average.")
        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": {"exploratory_queries": [], "repeated_queries": []}}
    
        print(f"--- 1. Exploratory Queries (eps = {args.eps}, budget = {args.budget}) ---")
        ledger1 = PrivacyLedger(args.budget, rng)
        true_answer = 100
        answered = 0
        refused = 0
        
        for i in range(1, args.queries + 1):
            res = ledger1.dp_count(true_answer, args.eps)
            if isinstance(res, str) and res.startswith("REFUSED"):
                refused += 1
                status = "refused"
                val = None
                print(f"Query {i:2d}: {res} (Budget remaining: {max(0, ledger1.budget):.2f})")
            else:
                answered += 1
                status = "answered"
                val = float(res)
                print(f"Query {i:2d}: Noisy Result = {val:.2f} (Budget remaining: {ledger1.budget:.2f})")
                
            results["tables"]["exploratory_queries"].append({
                "query_id": i,
                "status": status,
                "result": val,
                "budget_remaining": float(max(0, ledger1.budget))
            })
    
        print(f"\nSummary: {answered} queries answered, {refused} refused.")
        results["summary"]["answered_count"] = answered
        results["summary"]["refused_count"] = refused
    
        print(f"\n--- 2. Averaging Repeated Queries (eps = {args.eps}, repeats = {args.repeats}) ---")
        # Reset ledger for the averaging experiment
        ledger2 = PrivacyLedger(args.budget, rng)
        true_answer_2 = 500
        repeated_results = []
        
        for i in range(1, args.repeats + 1):
            res = ledger2.dp_count(true_answer_2, args.eps)
            if not isinstance(res, str):
                repeated_results.append(float(res))
                results["tables"]["repeated_queries"].append({
                    "query_id": i,
                    "result": float(res)
                })
    
        if repeated_results:
            average_result = np.mean(repeated_results)
            print(f"True Answer: {true_answer_2}")
            print(f"Individual noisy results: {[round(x, 1) for x in repeated_results]}")
            print(f"Averaged result ({len(repeated_results)} queries): {average_result:.2f}")
            print(f"Budget remaining: {ledger2.budget:.2f}")
            
            results["summary"]["true_answer_repeated"] = true_answer_2
            results["summary"]["average_repeated_result"] = float(average_result)
        else:
            print("Not enough budget to run repeated queries.")
    
        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 a-privacy-budget-ledger.py
    uv run a-privacy-budget-ledger.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--budget1 Initial global privacy budget.
--eps0.1 Privacy budget consumed per query.
--queries15 Number of exploratory queries to run.
--repeats10 Number of repeated queries to average.
--seed42 Random seed for reproducibility.

Our run

Budget remaining after each queryApple M2 · 8 cores · 8 GB
ε per query = {v}
Data table
Budget remaining after each query
QueryBudget remaining
10.9
20.8
30.7
40.6
50.5
60.4
70.3
80.2
90.1
100
110
120
130
140
150
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20 · 3 runs
Output · uv run a-privacy-budget-ledger.py --eps 0.1
--- 1. Exploratory Queries (eps = 0.1, budget = 1.0) ---
Query  1: Noisy Result = 107.94 (Budget remaining: 0.90)
Query  2: Noisy Result = 98.70 (Budget remaining: 0.80)
Query  3: Noisy Result = 112.63 (Budget remaining: 0.70)
Query  4: Noisy Result = 105.02 (Budget remaining: 0.60)
Query  5: Noisy Result = 83.31 (Budget remaining: 0.50)
Query  6: Noisy Result = 130.21 (Budget remaining: 0.40)
Query  7: Noisy Result = 107.39 (Budget remaining: 0.30)
Query  8: Noisy Result = 108.49 (Budget remaining: 0.20)
Query  9: Noisy Result = 86.38 (Budget remaining: 0.10)
Query 10: Noisy Result = 98.95 (Budget remaining: 0.00)
Query 11: REFUSED: Privacy budget depleted (Budget remaining: 0.00)
Query 12: REFUSED: Privacy budget depleted (Budget remaining: 0.00)
Query 13: REFUSED: Privacy budget depleted (Budget remaining: 0.00)
Query 14: REFUSED: Privacy budget depleted (Budget remaining: 0.00)
Query 15: REFUSED: Privacy budget depleted (Budget remaining: 0.00)

Summary: 10 queries answered, 5 refused.

--- 2. Averaging Repeated Queries (eps = 0.1, repeats = 10) ---
True Answer: 500
Individual noisy results: [497.0, 519.2, 503.4, 510.4, 498.8, 492.1, 501.2, 479.4, 510.6, 503.1]
Averaged result (10 queries): 501.52
Budget remaining: 0.00

What we found

At ε = 0.1, 10 queries were answered and 5 refused, exactly as the chapter expects. At ε = 0.05 all 15 were answered with half the budget left; at ε = 0.25 only 4 were.

Averaging ten answers to the same count brought it to 502 against a true value of 500, and spent the whole budget doing so.


Read it

A privacy budget is spent by reading, not by writing, which makes it unlike any other quota in a data platform: an analyst exploring the data uses it up. Averaging shows why a repeated query cannot be served from a cache for free: ten answers of noise scale 10 average to a much better estimate, which is exactly the leak the budget exists to bound. The budget must be charged per answer released, and its exhaustion is a product decision, not an error.