elector-dp-app / voting_dp.py
michael
Update app
9f2ca94
Raw
History Blame Contribute Delete
33.1 kB
"""
voting_dp.py
============
Differential Privacy (DP) mechanisms for privacy-preserving voting analytics.
All mechanisms use Local Differential Privacy (LDP), meaning each individual
perturbs their own data before it leaves their device / before it is recorded.
Mechanisms implemented
----------------------
Generic layer:
- randomized_response : Binary RR (ε-LDP, true/false values)
- k_randomized_response : k-RR (ε-LDP, k categorical values)
- laplace_mechanism : Laplace (ε-DP, numerical aggregates)
- gaussian_mechanism : Gaussian (ε,δ-DP, numerical aggregates)
- estimate_krr_frequency : Unbiased frequency estimator for k-RR outputs
- estimate_rr_frequency : Unbiased frequency estimator for binary RR outputs
- laplace_margin_of_error : 95 % margin of error for a Laplace-noised count
- laplace_confidence_interval : 95 % CI for a Laplace-noised count (noisy_value ± margin)
- rr_margin_of_error : 95 % margin of error for a de-biased binary-RR count
- rr_confidence_interval : 95 % CI for a de-biased binary-RR count (count ± margin)
- gaussian_margin_of_error : 95 % margin of error for a Gaussian-noised count
- gaussian_confidence_interval : 95 % CI for a Gaussian-noised count (noisy_value ± margin)
Application layer:
- protect_party_vote : (1) Protect which party a voter chose
- protect_voted_status : (2) Protect whether a person voted
- protect_city_vote_counts : (3a) Protect aggregate voter counts per city
- protect_city_party_counts : (3b) Protect per-party vote counts per city
- protect_potential_voter_status : (4) Protect whether someone is a potential voter
Usage example (Jupyter)
-----------------------
from voting_dp import protect_party_vote, protect_voted_status, ...
"""
from statistics import NormalDist
import numpy as np
import opendp.prelude as dp
from opendp.accuracy import laplacian_scale_to_accuracy, gaussian_scale_to_accuracy
dp.enable_features("contrib")
# =============================================================================
# GENERIC / PRIMITIVE DP MECHANISMS
# =============================================================================
def randomized_response(true_value, epsilon):
"""
Binary Randomized Response (ε-LDP).
Each user reports their true binary value with probability p, and flips
it with probability 1-p. Satisfies ε-Local Differential Privacy.
p = e^ε / (1 + e^ε)
Parameters
----------
true_value : bool
The real answer (True / False).
epsilon : float
Privacy budget (ε > 0). Larger ε → less noise → less privacy.
Returns
-------
bool
Perturbed (reported) value.
"""
if epsilon <= 0:
raise ValueError("epsilon must be positive.")
p_true = np.exp(epsilon) / (1.0 + np.exp(epsilon))
m = dp.m.make_randomized_response_bool(prob=p_true)
return m(bool(true_value))
def k_randomized_response(true_value, candidates, epsilon):
"""
k-Randomized Response (ε-LDP) for a categorical variable with k options.
With probability p the true value is reported; with probability 1-p a
value is drawn uniformly at random from the other k-1 candidates.
p = e^ε / (e^ε + k - 1)
Parameters
----------
true_value : any
The real answer; must be an element of `candidates`.
candidates : list
All k possible values of the categorical variable.
epsilon : float
Privacy budget (ε > 0).
Returns
-------
any
Perturbed (reported) value, drawn from `candidates`.
"""
if epsilon <= 0:
raise ValueError("epsilon must be positive.")
k = len(candidates)
if k < 2:
raise ValueError("candidates must contain at least 2 elements.")
if true_value not in candidates:
raise ValueError("true_value must be one of the candidates.")
p_true = np.exp(epsilon) / (np.exp(epsilon) + k - 1)
m = dp.m.make_randomized_response(categories=candidates, prob=p_true)
return m(true_value)
def laplace_mechanism(true_value, sensitivity, epsilon):
"""
Laplace Mechanism (ε-DP) for a numerical query.
Adds Laplace-distributed noise calibrated to global sensitivity / ε.
Noise ~ Laplace(0, sensitivity / ε)
Parameters
----------
true_value : float | int
The true numerical answer.
sensitivity : float
Global sensitivity of the query (Δf).
For a simple count query, sensitivity = 1.
epsilon : float
Privacy budget (ε > 0).
Returns
-------
float
Noisy numerical answer.
"""
if epsilon <= 0:
raise ValueError("epsilon must be positive.")
if sensitivity <= 0:
raise ValueError("sensitivity must be positive.")
scale = sensitivity / epsilon
# Use integer domain to avoid the NaN-exclusion requirement on float domains.
# Count queries are always integers; noise is still drawn from Laplace(0, scale).
m = dp.m.make_laplace(dp.atom_domain(T=int), dp.absolute_distance(T=int), scale)
return float(m(int(round(true_value))))
def gaussian_mechanism(true_value, sensitivity, epsilon, delta):
"""
Gaussian Mechanism (ε,δ-DP) for a numerical query.
Adds Gaussian noise with std σ = sensitivity * sqrt(2 * ln(1.25/δ)) / ε.
Note: OpenDP's make_gaussian uses zero-concentrated DP (zCDP) rather than
(ε,δ)-DP, so we retain the standard (ε,δ) calibration formula here and
draw noise directly with numpy. The resulting noise magnitude is identical;
only the formal privacy-accounting semantics differ.
Parameters
----------
true_value : float | int
The true numerical answer.
sensitivity : float
Global L2-sensitivity of the query.
For a simple count query, sensitivity = 1.
epsilon : float
Privacy budget (ε ∈ (0, 1]).
delta : float
Failure probability (δ ∈ (0, 1)). Typical value: 1e-5.
Returns
-------
float
Noisy numerical answer.
"""
if epsilon <= 0 or epsilon > 1:
raise ValueError("epsilon must be in (0, 1].")
if delta <= 0 or delta >= 1:
raise ValueError("delta must be in (0, 1).")
if sensitivity <= 0:
raise ValueError("sensitivity must be positive.")
sigma = sensitivity * np.sqrt(2.0 * np.log(1.25 / delta)) / epsilon
noise = np.random.normal(loc=0.0, scale=sigma)
return float(true_value) + noise
def double_randomized_response(true_value, epsilon1, epsilon2):
"""
Double Binary Randomized Response (sequential composition).
Applies binary RR twice in sequence. Used when a boolean value needs
two independent layers of privacy protection — for example, first
protecting whether a person voted, then protecting which party they
voted for, treating each dimension as a separate binary question.
Layer 1: r1 = RR(true_value, epsilon1)
Layer 2: r2 = RR(r1, epsilon2)
By sequential composition the total privacy budget is epsilon1 + epsilon2.
The effective probability of the final report being truthful is:
p_eff = p1 * p2 + (1 - p1) * (1 - p2)
where p1 = e^ε1 / (1+e^ε1) and p2 = e^ε2 / (1+e^ε2).
Parameters
----------
true_value : bool
epsilon1 : float Privacy budget for the first RR layer.
epsilon2 : float Privacy budget for the second RR layer.
Returns
-------
bool Twice-perturbed reported value.
"""
r1 = randomized_response(true_value, epsilon1)
r2 = randomized_response(r1, epsilon2)
return r2
def effective_p_double_rr(epsilon1, epsilon2):
"""
Return the effective truth-keeping probability for double binary RR.
p_eff = p1 * p2 + (1 - p1) * (1 - p2)
Parameters
----------
epsilon1, epsilon2 : float
Returns
-------
float
"""
p1 = np.exp(epsilon1) / (1.0 + np.exp(epsilon1))
p2 = np.exp(epsilon2) / (1.0 + np.exp(epsilon2))
return p1 * p2 + (1.0 - p1) * (1.0 - p2)
# =============================================================================
# UNBIASED ESTIMATORS (for aggregated / analysis use)
# =============================================================================
def estimate_rr_frequency(reported_values, epsilon):
"""
Unbiased frequency estimator for binary Randomized Response outputs.
Corrects for the deliberate noise introduced by `randomized_response` to
recover an unbiased estimate of the true proportion of True values.
p̂_true = (p̂_reported - (1-p)) / (2p - 1)
where p = e^ε / (1 + e^ε).
Parameters
----------
reported_values : array-like of bool
Collection of perturbed values from `randomized_response`.
epsilon : float
The ε used when the values were perturbed.
Returns
-------
float
Estimated true proportion of True in [0, 1] (clamped).
"""
p = np.exp(epsilon) / (1.0 + np.exp(epsilon))
reported = np.array(reported_values, dtype=float)
p_reported = reported.mean()
estimate = (p_reported - (1.0 - p)) / (2.0 * p - 1.0)
return float(np.clip(estimate, 0.0, 1.0))
def estimate_double_rr_frequency(reported_values, epsilon1, epsilon2):
"""
Unbiased frequency estimator for double binary Randomized Response outputs.
Mirrors `estimate_rr_frequency` but accounts for two sequential RR layers.
The effective flip probability is derived from p_eff, and the same
de-biasing formula applies:
est = (p_reported - (1 - p_eff)) / (2 * p_eff - 1)
Parameters
----------
reported_values : array-like of bool
Twice-perturbed values from `double_randomized_response`.
epsilon1, epsilon2 : float
The ε values used for layer 1 and layer 2 respectively.
Returns
-------
float
Estimated true proportion of True, clamped to [0, 1].
"""
p_eff = effective_p_double_rr(epsilon1, epsilon2)
reported = np.array(reported_values, dtype=float)
p_reported = reported.mean()
estimate = (p_reported - (1.0 - p_eff)) / (2.0 * p_eff - 1.0)
return float(np.clip(estimate, 0.0, 1.0))
def estimate_krr_frequency(reported_values, candidates, epsilon):
"""
Unbiased frequency estimator for k-Randomized Response outputs.
For each candidate c, the unbiased estimate of P(true = c) is:
p̂_c = (p̂_reported_c - 1/k) * k / (k * p_true - 1) [equivalent form]
= (p̂_reported_c * (k - 1) + p_true - 1) / ...
Simplified closed form per candidate:
p̂_c = ((k-1)*p̂_reported_c - (1 - p_true)) / (k * p_true - 1)
where p_true = e^ε / (e^ε + k - 1).
Parameters
----------
reported_values : array-like
Collection of perturbed values from `k_randomized_response`.
candidates : list
All k possible values (same list used during perturbation).
epsilon : float
The ε used when the values were perturbed.
Returns
-------
dict
{candidate: estimated_true_frequency}, frequencies sum to ≈ 1.
"""
k = len(candidates)
p = np.exp(epsilon) / (np.exp(epsilon) + k - 1)
reported = np.array(reported_values)
n = len(reported)
estimates = {}
for c in candidates:
p_reported_c = np.sum(reported == c) / n
# Correct closed form derived from inverting the k-RR channel:
# q_c = f_c * p + (1 - f_c) * (1-p)/(k-1)
# Solving for f_c gives:
# f_c = [(k-1)*q_c - (1-p)] / [k*p - 1]
estimate = ((k - 1) * p_reported_c - (1.0 - p)) / (k * p - 1.0)
estimates[c] = float(estimate) # keep raw (may be slightly negative)
# Project onto probability simplex: clip negatives then re-normalise
for c in candidates:
estimates[c] = max(0.0, estimates[c])
total = sum(estimates.values())
if total > 0:
estimates = {c: v / total for c, v in estimates.items()}
return estimates
# =============================================================================
# APPLICATION LAYER
# =============================================================================
# --- (1) Protect which party a voter voted for ---
def protect_party_vote(true_party, all_parties, epsilon):
"""
Protect the party a single voter voted for using k-Randomized Response.
This is the primary mechanism to hide individual party preferences while
still allowing aggregate statistics to be estimated at the population level.
Parameters
----------
true_party : any
The party the voter actually voted for (must be in `all_parties`).
all_parties : list
All k parties on the ballot.
epsilon : float
Privacy budget.
Returns
-------
any
A (possibly perturbed) reported party name.
"""
return k_randomized_response(true_party, all_parties, epsilon)
def protect_party_votes_batch(true_parties, all_parties, epsilon):
"""
Apply `protect_party_vote` to an array of voters.
Parameters
----------
true_parties : array-like
Sequence of true party choices, one per voter.
all_parties : list
All k parties on the ballot.
epsilon : float
Privacy budget.
Returns
-------
list
Perturbed party choices, one per voter.
"""
return [protect_party_vote(p, all_parties, epsilon) for p in true_parties]
# --- (2) Protect whether a voter has voted (tick-off from activist list) ---
def protect_voted_status(has_voted, epsilon):
"""
Protect whether a single person has voted using binary Randomized Response.
Used by party activists when ticking off voters from their list. The
reported status is noisy, preventing the activist from building a perfectly
accurate "who voted" record.
Parameters
----------
has_voted : bool
Whether the person has actually voted.
epsilon : float
Privacy budget.
Returns
-------
bool
Reported (possibly perturbed) voting status.
"""
return randomized_response(has_voted, epsilon)
def protect_voted_status_batch(has_voted_array, epsilon):
"""
Apply `protect_voted_status` to an array of voters.
Parameters
----------
has_voted_array : array-like of bool
Sequence of true voted statuses.
epsilon : float
Privacy budget.
Returns
-------
list of bool
Perturbed voted statuses.
"""
return [protect_voted_status(v, epsilon) for v in has_voted_array]
# --- (3a) Protect aggregate voter counts per city ---
def protect_city_vote_counts(city_counts, epsilon, sensitivity=1.0):
"""
Protect the total number of voters per city using the Laplace Mechanism.
A dictionary of {city: true_count} is transformed into a dictionary of
{city: noisy_count}. Counts are rounded to the nearest integer and
clipped to be non-negative.
Parameters
----------
city_counts : dict
{city_name: true_voter_count}
epsilon : float
Total privacy budget. This is split equally across all cities
(parallel composition: disjoint datasets → same ε per city is fine
under parallel composition).
sensitivity : float
Global sensitivity per city count. Defaults to 1 (one person
can change a city count by at most 1).
Returns
-------
dict
{city_name: noisy_voter_count} (non-negative integers)
"""
noisy = {}
for city, count in city_counts.items():
noisy_count = laplace_mechanism(count, sensitivity, epsilon)
noisy[city] = max(0, int(round(noisy_count)))
return noisy
# --- (3b) Protect per-party counts per city ---
def protect_city_party_counts(city_party_counts, epsilon, sensitivity=1.0):
"""
Protect the number of votes each party received per city (Laplace Mechanism).
Parameters
----------
city_party_counts : dict of dict
{city_name: {party_name: true_count}}
epsilon : float
Privacy budget per (city, party) cell. Under parallel composition
(disjoint city datasets), the same ε is used for every city.
sensitivity : float
Global sensitivity per cell. Defaults to 1.
Returns
-------
dict of dict
{city_name: {party_name: noisy_count}} (non-negative integers)
"""
noisy = {}
for city, party_counts in city_party_counts.items():
noisy[city] = {}
for party, count in party_counts.items():
noisy_count = laplace_mechanism(count, sensitivity, epsilon)
noisy[city][party] = max(0, int(round(noisy_count)))
return noisy
# --- (4) Protect potential voter eligibility flag ---
def protect_potential_voter_status(is_potential_voter, epsilon):
"""
Protect whether a person is flagged as a potential voter (binary RR).
Prevents precise lists of "who is a potential voter" from being perfectly
accurate, adding plausible deniability for each individual.
Parameters
----------
is_potential_voter : bool
Whether the person is genuinely flagged as a potential voter.
epsilon : float
Privacy budget.
Returns
-------
bool
Reported (possibly perturbed) potential-voter status.
"""
return randomized_response(is_potential_voter, epsilon)
def protect_potential_voter_status_batch(is_potential_array, epsilon):
"""
Apply `protect_potential_voter_status` to an array of people.
Parameters
----------
is_potential_array : array-like of bool
Sequence of true potential-voter flags.
epsilon : float
Privacy budget.
Returns
-------
list of bool
Perturbed potential-voter flags.
"""
return [protect_potential_voter_status(v, epsilon) for v in is_potential_array]
# =============================================================================
# CONVENIENCE: PRIVACY BUDGET REFERENCE
# =============================================================================
def epsilon_to_flip_probability(epsilon):
"""
Return the probability that a binary RR response is *wrong* (i.e. flipped).
Useful for explaining the privacy-utility tradeoff intuitively:
P(flip) = 1 / (1 + e^ε)
Parameters
----------
epsilon : float
Returns
-------
float
Probability that the reported value differs from the true value.
"""
return 1.0 / (1.0 + np.exp(epsilon))
def epsilon_to_krr_noise_probability(epsilon, k):
"""
Return the probability that a k-RR response is *wrong* (i.e. randomised).
P(report ≠ truth) = (k - 1) / (e^ε + k - 1)
Parameters
----------
epsilon : float
k : int
Number of categories.
Returns
-------
float
Probability that the reported value differs from the true value.
"""
return (k - 1) / (np.exp(epsilon) + k - 1)
# =============================================================================
# MARGIN OF ERROR AND CONFIDENCE INTERVALS FOR NUMERICAL DP MECHANISMS
# =============================================================================
def laplace_margin_of_error(sensitivity, epsilon, confidence=0.95, n=1):
"""
Margin of error for a Laplace-noised count query.
The noisy value produced by `laplace_mechanism` is an unbiased estimate
of the true count. The true value falls within ±margin of the noisy
value with the given confidence level:
true_value ∈ (noisy_value − margin, noisy_value + margin)
with probability = confidence.
For a single measurement (n=1) the exact Laplace quantile is used via
opendp.accuracy.laplacian_scale_to_accuracy. Averaging n independent
noisy counts shrinks the margin by √n; for n>1 the CLT approximation is
applied via opendp.accuracy.gaussian_scale_to_accuracy.
b = sensitivity / epsilon (Laplace scale)
n=1 → margin = b · ln(1/α) [exact, α = 1 − confidence]
n>1 → margin = z_(α/2) · b · √(2/n) [CLT]
Parameters
----------
sensitivity : float
Global sensitivity (Δf). For a count query, use 1.
epsilon : float
Privacy budget ε > 0.
confidence : float
Confidence level (default 0.95 for a 95% margin).
n : int
Number of independent noisy measurements averaged (default 1).
Returns
-------
float
Margin of error m. The true value lies in
(noisy_value − m, noisy_value + m) with probability `confidence`.
"""
if epsilon <= 0:
raise ValueError("epsilon must be positive.")
if sensitivity <= 0:
raise ValueError("sensitivity must be positive.")
if not (0.0 < confidence < 1.0):
raise ValueError("confidence must be in (0, 1).")
if n < 1:
raise ValueError("n must be at least 1.")
b = sensitivity / epsilon
alpha = 1.0 - confidence
if n == 1:
return float(laplacian_scale_to_accuracy(scale=b, alpha=alpha))
return float(gaussian_scale_to_accuracy(scale=b * np.sqrt(2.0 / n), alpha=alpha))
def laplace_confidence_interval(noisy_value, sensitivity, epsilon,
confidence=0.95, n=1):
"""
Confidence interval for the true value underlying a Laplace-noised query.
Returns (noisy_value − margin, noisy_value + margin) where `margin` is
computed by `laplace_margin_of_error`.
Parameters
----------
noisy_value : float
The DP-protected value from `laplace_mechanism`, or the arithmetic
mean of `n` such independent outputs.
sensitivity : float
Global sensitivity (Δf). For a count query, use 1.
epsilon : float
Privacy budget ε > 0.
confidence : float
Desired confidence level (default 0.95).
n : int
Number of independent noisy measurements averaged (default 1).
Returns
-------
tuple[float, float]
(lower_bound, upper_bound) confidence interval for the true value.
"""
margin = laplace_margin_of_error(sensitivity, epsilon, confidence, n)
return (noisy_value - margin, noisy_value + margin)
def rr_margin_of_error(n, epsilon, confidence=0.95, reported_rate=None):
"""
Margin of error for a count recovered from n binary-RR reports.
City / turnout counts in the DP Elector are *sums of the already-perturbed
RR reports*, de-biased by `estimate_rr_frequency`. No Laplace noise is
added: summing the stored reports is post-processing of the same ε spent at
capture, so it costs no extra privacy budget — and a second mechanism would
only be redundant double-noising of LDP data. The only error is therefore
the sampling variance of RR itself, and it shrinks as n grows.
The de-biased proportion estimator f̂ has variance
Var(f̂) = q(1 − q) / (n · (2p − 1)²), p = e^ε / (1 + e^ε)
where q = f·p + (1−f)(1−p) is the probability a single stored report reads
True. The margin on the *count* (= f̂ · n) at the given confidence is
margin = z · √(n · q(1 − q)) / (2p − 1).
With `reported_rate` (an estimate of q) unknown, the worst case q = ½ is
used, giving the widest (most conservative) interval.
Parameters
----------
n : int
Number of RR reports aggregated into the count.
epsilon : float
The ε used at capture (ε > 0).
confidence : float
Confidence level (default 0.95 for a 95 % margin).
reported_rate : float | None
Observed share of reports reading True (q). If None, uses ½.
Returns
-------
float
Margin of error on the recovered count.
"""
if epsilon <= 0:
raise ValueError("epsilon must be positive.")
if n < 1:
raise ValueError("n must be at least 1.")
if not (0.0 < confidence < 1.0):
raise ValueError("confidence must be in (0, 1).")
p = np.exp(epsilon) / (1.0 + np.exp(epsilon))
q = 0.5 if reported_rate is None else float(np.clip(reported_rate, 0.0, 1.0))
z = NormalDist().inv_cdf(1.0 - (1.0 - confidence) / 2.0)
return float(z * np.sqrt(n * q * (1.0 - q)) / (2.0 * p - 1.0))
def rr_confidence_interval(count, n, epsilon, confidence=0.95, reported_rate=None):
"""
Confidence interval for the true count underlying a de-biased binary-RR count.
Returns (count − margin, count + margin) where `margin` is computed by
`rr_margin_of_error`.
Parameters
----------
count : float
The de-biased count from `estimate_rr_frequency(...) * n`.
n : int
Number of RR reports aggregated into the count.
epsilon : float
The ε used at capture (ε > 0).
confidence : float
Desired confidence level (default 0.95).
reported_rate : float | None
Observed share of reports reading True (q). If None, uses ½.
Returns
-------
tuple[float, float]
(lower_bound, upper_bound) confidence interval for the true count.
"""
margin = rr_margin_of_error(n, epsilon, confidence, reported_rate)
return (count - margin, count + margin)
def gaussian_margin_of_error(sensitivity, epsilon, delta, confidence=0.95, n=1):
"""
Margin of error for a Gaussian-noised count query.
The true value falls within ±margin of the noisy value with the given
confidence level. Because the average of n Gaussians is Gaussian, this
is exact for any n ≥ 1 (no CLT approximation needed):
σ = sensitivity · √(2 · ln(1.25/δ)) / ε
margin = z_(α/2) · σ / √n
Uses opendp.accuracy.gaussian_scale_to_accuracy.
Parameters
----------
sensitivity : float
Global L2-sensitivity. For a count query, use 1.
epsilon : float
Privacy budget ε ∈ (0, 1].
delta : float
Failure probability δ ∈ (0, 1). Typical value: 1e-5.
confidence : float
Confidence level (default 0.95).
n : int
Number of independent noisy measurements averaged (default 1).
Returns
-------
float
Margin of error m.
"""
if epsilon <= 0 or epsilon > 1:
raise ValueError("epsilon must be in (0, 1].")
if delta <= 0 or delta >= 1:
raise ValueError("delta must be in (0, 1).")
if sensitivity <= 0:
raise ValueError("sensitivity must be positive.")
if not (0.0 < confidence < 1.0):
raise ValueError("confidence must be in (0, 1).")
if n < 1:
raise ValueError("n must be at least 1.")
sigma = sensitivity * np.sqrt(2.0 * np.log(1.25 / delta)) / epsilon
return float(gaussian_scale_to_accuracy(scale=sigma / np.sqrt(n),
alpha=1.0 - confidence))
def gaussian_confidence_interval(noisy_value, sensitivity, epsilon, delta,
confidence=0.95, n=1):
"""
Confidence interval for the true value underlying a Gaussian-noised query.
Returns (noisy_value − margin, noisy_value + margin) where `margin` is
computed by `gaussian_margin_of_error`.
Parameters
----------
noisy_value : float
The DP-protected value from `gaussian_mechanism`, or the arithmetic
mean of `n` such independent outputs.
sensitivity : float
Global L2-sensitivity. For a count query, use 1.
epsilon : float
Privacy budget ε ∈ (0, 1].
delta : float
Failure probability δ ∈ (0, 1). Typical value: 1e-5.
confidence : float
Desired confidence level (default 0.95).
n : int
Number of independent noisy measurements averaged (default 1).
Returns
-------
tuple[float, float]
(lower_bound, upper_bound) confidence interval for the true value.
"""
margin = gaussian_margin_of_error(sensitivity, epsilon, delta, confidence, n)
return (noisy_value - margin, noisy_value + margin)
# =============================================================================
# MAIN — USAGE EXAMPLES
# =============================================================================
def main():
"""Demonstrate each mechanism with realistic voting-analytics scenarios."""
np.random.seed(42)
W = 60 # column width for section headers
# =========================================================================
print("=" * W)
print(" 1. BINARY RANDOMIZED RESPONSE (voted / did not vote)")
print("=" * W)
# =========================================================================
# A party activist ticks off voters. Each tick is noised so that no
# individual's real status can be determined with certainty.
true_voted = True
eps = 1.0
flip_p = epsilon_to_flip_probability(eps)
print(f" True status : {true_voted} | ε={eps} | P(flip)={flip_p:.1%}")
print(f" 5 independent reports from the same person:")
for i in range(1, 6):
reported = randomized_response(true_voted, epsilon=eps)
tag = "correct" if reported == true_voted else "FLIPPED"
print(f" Run {i}: {str(reported):<5} ({tag})")
# =========================================================================
print()
print("=" * W)
print(" 2. k-RANDOMIZED RESPONSE (party preference)")
print("=" * W)
# =========================================================================
parties = ["Labour", "Conservative", "LibDem", "Green"]
true_party = "Labour"
eps = 1.5
wrong_p = epsilon_to_krr_noise_probability(eps, k=len(parties))
print(f" True party : {true_party} | k={len(parties)} |"
f" ε={eps} | P(randomised)={wrong_p:.1%}")
print(f" 5 independent reports from the same voter:")
for i in range(1, 6):
reported = k_randomized_response(true_party, parties, epsilon=eps)
tag = "correct" if reported == true_party else "randomised"
print(f" Run {i}: {reported:<14} ({tag})")
# =========================================================================
print()
print("=" * W)
print(" 3. LAPLACE MECHANISM (voter count for a city)")
print("=" * W)
# =========================================================================
true_count = 500
sensitivity = 1 # adding/removing one person changes the count by 1
eps = 0.5
noisy = laplace_mechanism(true_count, sensitivity=sensitivity, epsilon=eps)
margin = laplace_margin_of_error(sensitivity=sensitivity, epsilon=eps)
lo, hi = laplace_confidence_interval(noisy, sensitivity=sensitivity, epsilon=eps)
print(f" True count : {true_count}")
print(f" ε = {eps} → Laplace scale b = sensitivity/ε = {sensitivity/eps:.1f}")
print(f" Noisy count : {noisy:.1f}")
print(f" Margin of error : ±{margin:.1f} (95 % confidence)")
print(f" 95 % CI : ({lo:.1f}, {hi:.1f})")
print()
print(" Averaging daily counts over n days shrinks the margin by √n:")
print(f" {'n':>4} {'margin of error':>18} {'95 % CI width':>15}")
print(f" {'-'*42}")
for n in [1, 5, 10, 30]:
m_n = laplace_margin_of_error(sensitivity=sensitivity, epsilon=eps, n=n)
print(f" {n:>4} ±{m_n:>16.2f} {2*m_n:>14.2f}")
# =========================================================================
print()
print("=" * W)
print(" 4. GAUSSIAN MECHANISM (voter count for a city)")
print("=" * W)
# =========================================================================
true_count = 500
sensitivity = 1
eps = 0.5
delta = 1e-5
sigma = sensitivity * np.sqrt(2.0 * np.log(1.25 / delta)) / eps
noisy_g = gaussian_mechanism(true_count, sensitivity=sensitivity,
epsilon=eps, delta=delta)
margin_g = gaussian_margin_of_error(sensitivity=sensitivity,
epsilon=eps, delta=delta)
lo_g, hi_g = gaussian_confidence_interval(noisy_g, sensitivity=sensitivity,
epsilon=eps, delta=delta)
print(f" True count : {true_count}")
print(f" ε={eps}, δ={delta} → σ = {sigma:.2f}")
print(f" Noisy count : {noisy_g:.1f}")
print(f" Margin of error : ±{margin_g:.1f} (95 % confidence)")
print(f" 95 % CI : ({lo_g:.1f}, {hi_g:.1f})")
print()
print(" Averaging over n days (Gaussian CI shrinks exactly by √n):")
print(f" {'n':>4} {'margin of error':>18} {'95 % CI width':>15}")
print(f" {'-'*42}")
for n in [1, 5, 10, 30]:
m_n = gaussian_margin_of_error(sensitivity=sensitivity,
epsilon=eps, delta=delta, n=n)
print(f" {n:>4} ±{m_n:>16.2f} {2*m_n:>14.2f}")
if __name__ == "__main__":
main()