A privacy budget ledger
Simulate a modelNeeds Python with NumPy
Problem
Each differentially private answer spends some of a fixed privacy budget, epsilon; 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
Exactly 10 queries are answered in step 3, and 5 are refused. This is the "some budgets are depleted by reading" law as code.
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
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()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
| Knob · default | What 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
Data table
| Query | Budget remaining |
|---|---|
| 1 | 0.9 |
| 2 | 0.8 |
| 3 | 0.7 |
| 4 | 0.6 |
| 5 | 0.5 |
| 6 | 0.4 |
| 7 | 0.3 |
| 8 | 0.2 |
| 9 | 0.1 |
| 10 | 0 |
| 11 | 0 |
| 12 | 0 |
| 13 | 0 |
| 14 | 0 |
| 15 | 0 |
--- 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.00What 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.