riskcal 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
riskcal-0.1.0/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2024 Bogdan Kulynych
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
riskcal-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.1
2
+ Name: riskcal
3
+ Version: 0.1.0
4
+ Summary: Calibrate differentially private algorithms to operational privacy risk measures
5
+ Author: Bogdan Kulynych
6
+ Author-email: bogdan@kulyny.ch
7
+ Requires-Python: >=3.9,<3.13
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: dp-accounting (>=0.4.4,<0.5.0)
14
+ Requires-Dist: scipy (>=1.11.3,<2.0.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ ## riskcal
18
+
19
+ [![CI](https://github.com/bogdan-kulynych/riskcal/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bogdan-kulynych/riskcal/actions/workflows/ci.yml)
20
+ [![arXiv](https://img.shields.io/badge/arXiv-2407.02191-b31b1b.svg)](https://arxiv.org/abs/2407.02191)
21
+
22
+ ---
23
+
24
+ ⚠️ This is a research prototype. Avoid or be extra careful when using in production.
25
+
26
+ ---
27
+
28
+ The library provides tools for calibrating the noise scale in (epsilon, delta)-DP mechanisms to one
29
+ of the two notions of operational attack risk (attack accuracy/advantage, or attack TPR and FPR) instead of the
30
+ (epsilon, delta) parameters, as well as for efficient measurement of these notions.
31
+ The library enables to reduce the noise scale at the same level of targeted attack risk.
32
+
33
+
34
+ ### Using the Library
35
+
36
+ Install with:
37
+ ```
38
+ pip install riskcal
39
+ ```
40
+
41
+ #### Quickstart
42
+
43
+ ##### Measuring the Exact f-DP / Trade-Off Curve for any DP Mechanism
44
+ To measure the attack trade-off curve (equivalent to attack's receiver-operating curve) for DP-SGD, you can run
45
+ ```
46
+ import riskcal
47
+ import numpy as np
48
+
49
+ alphas = np.array([0.01, 0.05, 0.1])
50
+ betas = riskcal.pld.get_beta(
51
+ alpha=alphas,
52
+ noise_multiplier=noise_multiplier,
53
+ sample_rate=sample_rate,
54
+ num_steps=num_steps,
55
+ )
56
+ ```
57
+
58
+ You can also get the trade-off curve for any DP mechanism [supported](https://github.com/google/differential-privacy/tree/0b109e959470c43e9f177d5411603b70a56cdc7a/python/dp_accounting)
59
+ by Google's DP accounting library, given its privacy loss distribution (PLD):
60
+ ```
61
+ import riskcal
62
+ import numpy as np
63
+
64
+ alphas = np.array([0.01, 0.05, 0.1])
65
+ betas = riskcal.pld.get_beta_from_pld(pld, alpha=alphas)
66
+ ```
67
+
68
+ ##### Calibrating DP-SGD to attack FNR/FPR
69
+ To calibrate noise scale in DP-SGD to a given attack FPR (beta) and FNR (alpha), run:
70
+ ```
71
+ import riskcal
72
+
73
+ noise_multiplier = riskcal.pld.find_noise_multiplier_for_err_rates(
74
+ beta=0.2,
75
+ alpha=0.01,
76
+ sample_rate=sample_rate,
77
+ num_steps=num_steps
78
+ )
79
+ ```
80
+
@@ -0,0 +1,63 @@
1
+ ## riskcal
2
+
3
+ [![CI](https://github.com/bogdan-kulynych/riskcal/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bogdan-kulynych/riskcal/actions/workflows/ci.yml)
4
+ [![arXiv](https://img.shields.io/badge/arXiv-2407.02191-b31b1b.svg)](https://arxiv.org/abs/2407.02191)
5
+
6
+ ---
7
+
8
+ ⚠️ This is a research prototype. Avoid or be extra careful when using in production.
9
+
10
+ ---
11
+
12
+ The library provides tools for calibrating the noise scale in (epsilon, delta)-DP mechanisms to one
13
+ of the two notions of operational attack risk (attack accuracy/advantage, or attack TPR and FPR) instead of the
14
+ (epsilon, delta) parameters, as well as for efficient measurement of these notions.
15
+ The library enables to reduce the noise scale at the same level of targeted attack risk.
16
+
17
+
18
+ ### Using the Library
19
+
20
+ Install with:
21
+ ```
22
+ pip install riskcal
23
+ ```
24
+
25
+ #### Quickstart
26
+
27
+ ##### Measuring the Exact f-DP / Trade-Off Curve for any DP Mechanism
28
+ To measure the attack trade-off curve (equivalent to attack's receiver-operating curve) for DP-SGD, you can run
29
+ ```
30
+ import riskcal
31
+ import numpy as np
32
+
33
+ alphas = np.array([0.01, 0.05, 0.1])
34
+ betas = riskcal.pld.get_beta(
35
+ alpha=alphas,
36
+ noise_multiplier=noise_multiplier,
37
+ sample_rate=sample_rate,
38
+ num_steps=num_steps,
39
+ )
40
+ ```
41
+
42
+ You can also get the trade-off curve for any DP mechanism [supported](https://github.com/google/differential-privacy/tree/0b109e959470c43e9f177d5411603b70a56cdc7a/python/dp_accounting)
43
+ by Google's DP accounting library, given its privacy loss distribution (PLD):
44
+ ```
45
+ import riskcal
46
+ import numpy as np
47
+
48
+ alphas = np.array([0.01, 0.05, 0.1])
49
+ betas = riskcal.pld.get_beta_from_pld(pld, alpha=alphas)
50
+ ```
51
+
52
+ ##### Calibrating DP-SGD to attack FNR/FPR
53
+ To calibrate noise scale in DP-SGD to a given attack FPR (beta) and FNR (alpha), run:
54
+ ```
55
+ import riskcal
56
+
57
+ noise_multiplier = riskcal.pld.find_noise_multiplier_for_err_rates(
58
+ beta=0.2,
59
+ alpha=0.01,
60
+ sample_rate=sample_rate,
61
+ num_steps=num_steps
62
+ )
63
+ ```
@@ -0,0 +1,40 @@
1
+ [tool.poetry]
2
+ name = "riskcal"
3
+ version = "0.1.0"
4
+ description = "Calibrate differentially private algorithms to operational privacy risk measures"
5
+ authors = ["Bogdan Kulynych <bogdan@kulyny.ch>", "Juan Felipe Gomez <juangomez@g.harvard.edu>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = ">=3.9,<3.13"
10
+ scipy = "^1.11.3"
11
+ dp-accounting = "^0.4.4"
12
+
13
+ [tool.poetry.group.dev]
14
+ optional = true
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ pytest = "^7.4.2"
18
+ opacus = "^1.4.0"
19
+ pre-commit = "^3.5.0"
20
+ ipdb = "^0.13.13"
21
+
22
+ [tool.poetry.group.experiments]
23
+ optional = true
24
+
25
+ [tool.poetry.group.experiments.dependencies]
26
+ seaborn = "^0.13.0"
27
+ pandas = "^2.1.1"
28
+ jupyter = "^1.0.0"
29
+ jupytext = "^1.15.2"
30
+ tqdm = "^4.66.1"
31
+ torch = "==2.0.0"
32
+ prv-accountant = "^0.2.0"
33
+
34
+ [tool.pytest.ini_options]
35
+ addopts = ["-v"]
36
+ testpaths = ["tests"]
37
+
38
+ [build-system]
39
+ requires = ["poetry-core"]
40
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,3 @@
1
+ from . import blackbox
2
+ from . import utils
3
+ from . import pld
@@ -0,0 +1,266 @@
1
+ from dataclasses import dataclass
2
+ import warnings
3
+
4
+ import numpy as np
5
+ from scipy.optimize import root_scalar, minimize_scalar
6
+
7
+ from . import utils
8
+
9
+
10
+ def find_noise_multiplier_for_epsilon_delta(
11
+ accountant: "opacus.accountants.accountant.IAccountant",
12
+ sample_rate: float,
13
+ num_steps: int,
14
+ epsilon: float,
15
+ delta: float,
16
+ eps_error: float = 0.001,
17
+ mu_max: float = 100.0,
18
+ **accountant_kwargs,
19
+ ) -> float:
20
+ """
21
+ Find a noise multiplier that satisfies a given target epsilon.
22
+ Adapted from https://github.com/microsoft/prv_accountant/blob/main/prv_accountant/dpsgd.py
23
+
24
+ :param accountant: Opacus-compatible accountant
25
+ :param sample_rate: Probability of a record being in batch for Poisson sampling
26
+ :param num_steps: Number of optimisation steps
27
+ :param epsilon: Desired target epsilon
28
+ :param delta: Value of DP delta
29
+ :param float eps_error: Error allowed for final epsilon
30
+ :param float mu_max: Maximum value of noise multiplier of the search.
31
+ :param accountant_kwargs: Parameters passed to the accountant's `get_epsilon`
32
+ """
33
+
34
+ def compute_epsilon(mu: float) -> float:
35
+ acc = accountant()
36
+ for step in range(num_steps):
37
+ acc.step(noise_multiplier=mu, sample_rate=sample_rate)
38
+
39
+ return acc.get_epsilon(delta=delta, **accountant_kwargs)
40
+
41
+ mu_R = 1.0
42
+ eps_R = float("inf")
43
+ while eps_R > epsilon:
44
+ mu_R *= np.sqrt(2)
45
+ try:
46
+ eps_R = compute_epsilon(mu_R)
47
+ except (OverflowError, RuntimeError):
48
+ pass
49
+ if mu_R > mu_max:
50
+ raise RuntimeError(
51
+ "Finding a suitable noise multiplier has not converged. "
52
+ "Try increasing target epsilon or decreasing sample rate."
53
+ )
54
+
55
+ mu_L = mu_R
56
+ eps_L = eps_R
57
+ while eps_L <= epsilon:
58
+ mu_L /= np.sqrt(2)
59
+ eps_L = compute_epsilon(mu_L)
60
+
61
+ has_converged = False
62
+ bracket = [mu_L, mu_R]
63
+ while not has_converged:
64
+ mu_err = (bracket[1] - bracket[0]) * 0.01
65
+ assert mu_err > 0
66
+ mu_guess = root_scalar(
67
+ lambda mu: compute_epsilon(mu) - epsilon,
68
+ bracket=bracket,
69
+ xtol=mu_err,
70
+ ).root
71
+ bracket = [mu_guess - mu_err, mu_guess + mu_err]
72
+ eps_up = compute_epsilon(mu_guess - mu_err)
73
+ eps_low = compute_epsilon(mu_guess + mu_err)
74
+ has_converged = (eps_up - eps_low) < eps_error
75
+ assert compute_epsilon(bracket[1]) < epsilon + eps_error
76
+ return bracket[1]
77
+
78
+
79
+ def find_noise_multiplier_for_advantage(
80
+ accountant: "opacus.accountants.accountant.IAccountant",
81
+ advantage: float,
82
+ sample_rate: float,
83
+ num_steps: float,
84
+ eps_error=0.001,
85
+ mu_max=100.0,
86
+ **accountant_kwargs,
87
+ ):
88
+ """
89
+ Find a noise multiplier that satisfies given levels of attack advantage.
90
+
91
+ :param accountant: Opacus-compatible accountant
92
+ :param advantage: Attack advantage bound
93
+ :param sample_rate: Probability of a record being in batch for Poisson sampling
94
+ :param num_steps: Number of optimisation steps
95
+ :param float eps_error: Error allowed for final epsilon
96
+ :param float mu_max: Maximum value of noise multiplier of the search
97
+ :param accountant_kwargs: Parameters passed to the accountant's `get_epsilon`
98
+ """
99
+ return find_noise_multiplier_for_epsilon_delta(
100
+ accountant=accountant,
101
+ sample_rate=sample_rate,
102
+ num_steps=num_steps,
103
+ epsilon=0.0,
104
+ delta=advantage,
105
+ eps_error=eps_error,
106
+ mu_max=mu_max,
107
+ **accountant_kwargs,
108
+ )
109
+
110
+
111
+ class _ErrRatesAccountant:
112
+ def __init__(
113
+ self,
114
+ accountant,
115
+ alpha,
116
+ beta,
117
+ sample_rate,
118
+ num_steps,
119
+ eps_error,
120
+ mu_max=100.0,
121
+ **accountant_kwargs,
122
+ ):
123
+ self.accountant = accountant
124
+ self.alpha = alpha
125
+ self.beta = beta
126
+ self.sample_rate = sample_rate
127
+ self.num_steps = num_steps
128
+ self.eps_error = eps_error
129
+ self.mu_max = mu_max
130
+ self.accountant_kwargs = accountant_kwargs
131
+
132
+ def find_noise_multiplier(self, delta):
133
+ epsilon = utils.get_epsilon_for_err_rates(delta, self.alpha, self.beta)
134
+ try:
135
+ mu = find_noise_multiplier_for_epsilon_delta(
136
+ epsilon=epsilon,
137
+ delta=delta,
138
+ accountant=self.accountant,
139
+ sample_rate=self.sample_rate,
140
+ num_steps=self.num_steps,
141
+ eps_error=self.eps_error,
142
+ mu_max=self.mu_max,
143
+ **self.accountant_kwargs,
144
+ )
145
+ return mu
146
+
147
+ except RuntimeError as e:
148
+ warnings.warn(
149
+ f"Error occured in grid search w/ {epsilon=:.4f} {delta=:.4f}"
150
+ )
151
+ warnings.warn(e)
152
+ return np.inf
153
+
154
+
155
+ def _find_noise_profile(
156
+ err_rates_acct_obj: _ErrRatesAccountant,
157
+ max_delta: float,
158
+ sample_rate: float,
159
+ num_steps: float,
160
+ delta_error=0.01,
161
+ eps_error=0.001,
162
+ mu_max=100.0,
163
+ **accountant_kwargs,
164
+ ):
165
+ delta_vals = np.linspace(
166
+ delta_error, max_delta, int((max_delta - delta_error) / delta_error)
167
+ )
168
+ if len(delta_vals) == 0:
169
+ raise ValueError("Grid resolution too low. Try increasing delta_error.")
170
+
171
+ noise_vals = np.array([np.inf] * len(delta_vals))
172
+ for i, delta in enumerate(delta_vals):
173
+ noise_vals[i] = err_rates_acct_obj.find_noise_multiplier(delta)
174
+
175
+ return delta_vals, noise_vals
176
+
177
+
178
+ @dataclass
179
+ class CalibrationResult:
180
+ """
181
+ Result of generic calibration.
182
+ """
183
+
184
+ noise_multiplier: float
185
+ calibration_epsilon: float
186
+ calibration_delta: float
187
+
188
+
189
+ def find_noise_multiplier_for_err_rates(
190
+ accountant: "opacus.accountants.accountant.IAccountant",
191
+ alpha: float,
192
+ beta: float,
193
+ sample_rate: float,
194
+ num_steps: float,
195
+ delta_error=0.01,
196
+ eps_error=0.001,
197
+ mu_max=100.0,
198
+ method="brent",
199
+ **accountant_kwargs,
200
+ ):
201
+ """
202
+ Find a noise multiplier that limits attack FPR/FNR rates.
203
+
204
+ :param accountant: Opacus-compatible accountant
205
+ :param alpha: Attack FPR bound
206
+ :param beta: Attack FNR bound
207
+ :param sample_rate: Probability of a record being in batch for Poisson sampling
208
+ :param num_steps: Number of optimisation steps
209
+ :param float delta_error: Error allowed for delta used for calibration
210
+ :param float eps_error: Error allowed for final epsilon
211
+ :param float mu_max: Maximum value of noise multiplier of the search
212
+ :param str method: Optimization method. One of ['brent', 'grid_search']
213
+ :param accountant_kwargs: Parameters passed to the accountant's `get_epsilon`
214
+ """
215
+ if alpha + beta >= 1:
216
+ raise ValueError(
217
+ f"The guarantees are vacuous when alpha + beta >= 1. Got {alpha=}, {beta=}"
218
+ )
219
+
220
+ max_delta = 1 - alpha - beta
221
+ err_rates_acct_obj = _ErrRatesAccountant(
222
+ accountant=accountant,
223
+ alpha=alpha,
224
+ beta=beta,
225
+ sample_rate=sample_rate,
226
+ num_steps=num_steps,
227
+ eps_error=eps_error,
228
+ mu_max=mu_max,
229
+ **accountant_kwargs,
230
+ )
231
+
232
+ if max_delta < delta_error:
233
+ raise ValueError(f"{delta_error=} too low for the requested error rates.")
234
+
235
+ if method == "brent":
236
+ opt_result = minimize_scalar(
237
+ err_rates_acct_obj.find_noise_multiplier,
238
+ bounds=[delta_error, max_delta],
239
+ options=dict(xatol=delta_error),
240
+ )
241
+ if not opt_result.success:
242
+ raise RuntimeError(f"Optimization failed: {opt_result.message}")
243
+ calibration_delta = opt_result.x
244
+ noise_multiplier = opt_result.fun
245
+
246
+ elif method == "grid_search":
247
+ delta_vals, noise_vals = _find_noise_profile(
248
+ err_rates_acct_obj=err_rates_acct_obj,
249
+ max_delta=max_delta,
250
+ sample_rate=sample_rate,
251
+ num_steps=num_steps,
252
+ delta_error=delta_error,
253
+ eps_error=eps_error,
254
+ mu_max=mu_max,
255
+ )
256
+
257
+ noise_multiplier = noise_vals.min()
258
+ calibration_delta = delta_vals[np.argmin(noise_vals)]
259
+
260
+ return CalibrationResult(
261
+ noise_multiplier=noise_multiplier,
262
+ calibration_delta=calibration_delta,
263
+ calibration_epsilon=utils.get_epsilon_for_err_rates(
264
+ calibration_delta, alpha, beta
265
+ ),
266
+ )
@@ -0,0 +1,317 @@
1
+ from functools import reduce
2
+ from typing import Union
3
+
4
+ import numpy as np
5
+ from dp_accounting.pld import privacy_loss_distribution
6
+ from scipy.optimize import root_scalar
7
+
8
+
9
+ class CTDAccountant:
10
+ """
11
+ Opacus-compatible Connect the Dots accountant.
12
+ """
13
+ def __init__(self):
14
+ self.history = []
15
+
16
+ def step(self, *, noise_multiplier, sample_rate):
17
+ if len(self.history) > 1:
18
+ prev_noise_multiplier, prev_sample_rate, prev_steps = self.history[-1]
19
+ if (
20
+ prev_noise_multiplier == noise_multiplier
21
+ and prev_sample_rate == sample_rate
22
+ ):
23
+ self.history[-1] = (noise_multiplier, sample_rate, prev_steps + 1)
24
+ return
25
+ self.history.append((noise_multiplier, sample_rate, 1))
26
+
27
+ def get_epsilon(self, *, delta, grid_step=1e-4, **kwargs):
28
+ plds = []
29
+ for noise_multiplier, sample_rate, num_steps in self.history:
30
+ pld = privacy_loss_distribution.from_gaussian_mechanism(
31
+ standard_deviation=noise_multiplier,
32
+ sampling_prob=sample_rate,
33
+ use_connect_dots=True,
34
+ value_discretization_interval=grid_step,
35
+ )
36
+ plds.append(pld.self_compose(num_steps))
37
+
38
+ composed_pld = reduce(lambda a, b: a.compose(b), plds)
39
+ return composed_pld.get_epsilon_for_delta(delta)
40
+
41
+ def __len__(self):
42
+ total = 0
43
+ for _, _, steps in self.history:
44
+ total += steps
45
+ return total
46
+
47
+ def mechanism(self):
48
+ return "ctd"
49
+
50
+ # The following methods are copied from https://opacus.ai/api/_modules/opacus/accountants/accountant.html#IAccountant
51
+ # to avoid the direct dependence on the opacus package.
52
+ def get_optimizer_hook_fn(
53
+ self, sample_rate: float
54
+ ):
55
+ """
56
+ Returns a callback function which can be used to attach to DPOptimizer
57
+ Args:
58
+ sample_rate: Expected sampling rate used for accounting
59
+ """
60
+
61
+ def hook_fn(optim):
62
+ # This works for Poisson for both single-node and distributed
63
+ # The reason is that the sample rate is the same in both cases (but in
64
+ # distributed mode, each node samples among a subset of the data)
65
+ self.step(
66
+ noise_multiplier=optim.noise_multiplier,
67
+ sample_rate=sample_rate * optim.accumulated_iterations,
68
+ )
69
+
70
+ return hook_fn
71
+
72
+ def state_dict(self, destination=None):
73
+ """
74
+ Returns a dictionary containing the state of the accountant.
75
+ Args:
76
+ destination: a mappable object to populate the current state_dict into.
77
+ If this arg is None, an OrderedDict is created and populated.
78
+ Default: None
79
+ """
80
+ if destination is None:
81
+ destination = OrderedDict()
82
+ destination["history"] = deepcopy(self.history)
83
+ destination["mechanism"] = self.__class__.mechanism
84
+ return destination
85
+
86
+ def load_state_dict(self, state_dict):
87
+ """
88
+ Validates the supplied state_dict and populates the current
89
+ Privacy Accountant's state dict.
90
+
91
+ Args:
92
+ state_dict: state_dict to load.
93
+
94
+ Raises:
95
+ ValueError if supplied state_dict is invalid and cannot be loaded.
96
+ """
97
+ if state_dict is None or len(state_dict) == 0:
98
+ raise ValueError(
99
+ "state dict is either None or empty and hence cannot be loaded"
100
+ " into Privacy Accountant."
101
+ )
102
+ if "history" not in state_dict.keys():
103
+ raise ValueError(
104
+ "state_dict does not have the key `history`."
105
+ " Cannot be loaded into Privacy Accountant."
106
+ )
107
+ if "mechanism" not in state_dict.keys():
108
+ raise ValueError(
109
+ "state_dict does not have the key `mechanism`."
110
+ " Cannot be loaded into Privacy Accountant."
111
+ )
112
+ if self.__class__.mechanism != state_dict["mechanism"]:
113
+ raise ValueError(
114
+ f"state_dict of {state_dict['mechanism']} cannot be loaded into "
115
+ f" Privacy Accountant with mechanism {self.__class__.mechanism}"
116
+ )
117
+ self.history = state_dict["history"]
118
+
119
+
120
+ def _get_domain_and_pmf_from_pld(pld):
121
+ pld = pld.to_dense_pmf()
122
+ pmf = pld._probs
123
+ domain = (pld._lower_loss + np.arange(len(pld._probs))) * pld._discretization
124
+ return domain, pmf
125
+
126
+
127
+ def _get_lower_loss_and_pmf_from_pld(pld):
128
+ pld = pld.to_dense_pmf()
129
+ pmf = pld._probs
130
+ lower_loss = pld._lower_loss
131
+ return lower_loss, pmf
132
+
133
+
134
+ def get_beta_from_pld(
135
+ pld: privacy_loss_distribution.PrivacyLossDistribution,
136
+ alpha: Union[float, np.ndarray],
137
+ ):
138
+ lower_loss_Y, pmf_Y = _get_lower_loss_and_pmf_from_pld(
139
+ pld._pmf_remove
140
+ )
141
+ lower_loss_Z, pmf_Z = _get_lower_loss_and_pmf_from_pld(pld._pmf_add)
142
+
143
+ # Get the discrete points of alpha, beta
144
+ alphas = np.cumsum(pmf_Z) - pmf_Z
145
+ betas = np.cumsum(pmf_Y)
146
+
147
+ # Binary search to find the right index.
148
+ idx_Z = np.searchsorted(alphas, alpha) - 1
149
+
150
+ # Sanity check: did we find the correct index?
151
+ # Note that the alphas are in descending order.
152
+ assert np.all(alphas[idx_Z] < alpha) and np.all(alpha < alphas[idx_Z + 1])
153
+
154
+ # Find gamma.
155
+ gamma = (alpha - alphas[idx_Z]) / pmf_Z[idx_Z]
156
+
157
+ # Sanity check: gamma should be a positive and less than 1
158
+ assert np.all(0 < gamma) and np.all(gamma < 1)
159
+
160
+ # Get index in the Y world
161
+ idx_Y = -lower_loss_Z - lower_loss_Y - idx_Z
162
+
163
+ # Compute beta.
164
+ beta = betas[idx_Y] - gamma * pmf_Y[idx_Y]
165
+
166
+ # Sanity check: did we somehow go over to the next beta?
167
+ assert np.all(betas[idx_Y - 1] < beta) and np.all(beta < betas[idx_Y])
168
+
169
+ return beta
170
+
171
+
172
+ def get_beta(
173
+ alpha: Union[float, np.ndarray],
174
+ noise_multiplier: float,
175
+ sample_rate: float,
176
+ num_steps: int,
177
+ grid_step=1e-4,
178
+ ):
179
+ """
180
+ Find FNR for a given FPR in DP-SGD.
181
+
182
+ Arguments:
183
+ alpha: Target FPR, either a single float or a numpy array
184
+ noise_multiplier: DP-SGD noise multiplier
185
+ sample_rate: Subsampled Gaussian sampling rate
186
+ num_steps: Number of steps
187
+ gird_step: Step size of the discretization grid
188
+ """
189
+ num_steps = int(num_steps)
190
+
191
+ pld = privacy_loss_distribution.from_gaussian_mechanism(
192
+ standard_deviation=noise_multiplier,
193
+ sampling_prob=sample_rate,
194
+ use_connect_dots=True,
195
+ value_discretization_interval=grid_step,
196
+ )
197
+ pld = pld.self_compose(num_steps)
198
+ return get_beta_from_pld(pld, alpha=alpha)
199
+
200
+
201
+ def get_advantage(
202
+ noise_multiplier: float,
203
+ sample_rate: float,
204
+ num_steps: int,
205
+ grid_step=1e-4,
206
+ ):
207
+ google_pld = privacy_loss_distribution.from_gaussian_mechanism(
208
+ standard_deviation=noise_multiplier,
209
+ sampling_prob=sample_rate,
210
+ use_connect_dots=True,
211
+ value_discretization_interval=grid_step,
212
+ )
213
+ composed_google_pld = google_pld.self_compose(num_steps)
214
+ return composed_google_pld.get_delta_for_epsilon(0)
215
+
216
+
217
+ def find_noise_multiplier_for_err_rates(
218
+ alpha: float,
219
+ beta: float,
220
+ sample_rate: float,
221
+ num_steps: float,
222
+ grid_step: float = 1e-4,
223
+ mu_max=100.0,
224
+ beta_error=0.001,
225
+ ):
226
+ """
227
+ Find a noise multiplier that satisfies a given target epsilon.
228
+ Adapted from https://github.com/microsoft/prv_accountant/blob/main/prv_accountant/dpsgd.py
229
+
230
+ :param alpha: Attack FPR bound
231
+ :param beta: Attack FNR bound
232
+ :param sample_rate: Probability of a record being in batch for Poisson sampling
233
+ :param num_steps: Number of optimisation steps
234
+ :param grid_step: Discretization grid step
235
+ :param delta: Value of DP delta
236
+ :param float mu_max: Maximum value of noise multiplier of the search.
237
+ """
238
+
239
+ def _get_beta(mu):
240
+ return get_beta(
241
+ noise_multiplier=mu,
242
+ alpha=alpha,
243
+ sample_rate=sample_rate,
244
+ num_steps=num_steps,
245
+ grid_step=grid_step,
246
+ )
247
+
248
+ mu_R = 1.0
249
+ beta_R = 0.0
250
+ while beta_R < beta:
251
+ mu_R *= np.sqrt(2)
252
+ try:
253
+ beta_R = _get_beta(mu_R)
254
+ except (OverflowError, RuntimeError):
255
+ pass
256
+ if mu_R > mu_max:
257
+ raise RuntimeError(
258
+ "Finding a suitable noise multiplier has not converged. "
259
+ "Try decreasing target beta or decreasing sample rate."
260
+ )
261
+
262
+ mu_L = mu_R
263
+ beta_L = beta_R
264
+ while beta_L > beta:
265
+ mu_L /= np.sqrt(2)
266
+ beta_L = _get_beta(mu_L)
267
+
268
+ has_converged = False
269
+ bracket = [mu_L, mu_R]
270
+ while not has_converged:
271
+ mu_err = (bracket[1] - bracket[0]) * 0.01
272
+
273
+ mu_guess = root_scalar(
274
+ lambda mu: _get_beta(mu) - beta,
275
+ bracket=bracket,
276
+ xtol=mu_err,
277
+ ).root
278
+ bracket = [mu_guess - mu_err, mu_guess + mu_err]
279
+ beta_low = _get_beta(mu_guess - mu_err)
280
+ beta_up = _get_beta(mu_guess + mu_err)
281
+ has_converged = (beta_up - beta_low) < beta_error
282
+
283
+ assert _get_beta(bracket[1]) > beta - beta_error
284
+ return bracket[1]
285
+
286
+
287
+ def find_noise_multiplier_for_advantage(
288
+ advantage: float,
289
+ sample_rate: float,
290
+ num_steps: float,
291
+ grid_step: float = 1e-4,
292
+ mu_max=100.0,
293
+ advantage_error=0.001,
294
+ ):
295
+ """
296
+ Find a noise multiplier that satisfies a given target advantage.
297
+ Adapted from https://github.com/microsoft/prv_accountant/blob/main/prv_accountant/dpsgd.py
298
+
299
+ :param alpha: Attack FPR bound
300
+ :param beta: Attack FNR bound
301
+ :param sample_rate: Probability of a record being in batch for Poisson sampling
302
+ :param num_steps: Number of optimisation steps
303
+ :param grid_step: Discretization grid step
304
+ :param delta: Value of DP delta
305
+ :param float mu_max: Maximum value of noise multiplier of the search.
306
+ """
307
+ # Solve advantage = 1 - 2 * fp
308
+ fp = -0.5 * (advantage - 1)
309
+ return find_noise_multiplier_for_err_rates(
310
+ alpha=fp,
311
+ beta=fp,
312
+ sample_rate=sample_rate,
313
+ num_steps=num_steps,
314
+ grid_step=grid_step,
315
+ mu_max=mu_max,
316
+ beta_error=advantage_error,
317
+ )
@@ -0,0 +1,66 @@
1
+ import numpy as np
2
+ from scipy.optimize import root_scalar
3
+
4
+
5
+ def get_adv_for_epsilon_delta(epsilon: float, delta: float) -> float:
6
+ """Derive advantage from a given epsilon and delta.
7
+
8
+ >>> round(get_adv_for_epsilon_delta(0., 0.001), 3)
9
+ 0.001
10
+ """
11
+ return (np.exp(epsilon) + 2 * delta - 1) / (np.exp(epsilon) + 1)
12
+
13
+
14
+ def get_epsilon_for_advantage(delta: float, adv: float) -> float:
15
+ """Derive epsilon from a given advantage and delta.
16
+
17
+ >>> round(get_epsilon_for_advantage(0.001, 0.5), 3)
18
+ 1.097
19
+ """
20
+ # We define the function to find the root of (f(eps) - 0) = 0
21
+ root_result = root_scalar(
22
+ lambda epsilon: get_adv_for_epsilon_delta(epsilon, delta) - adv,
23
+ method="brentq",
24
+ bracket=(0, 100),
25
+ )
26
+
27
+ if root_result.converged:
28
+ return root_result.root
29
+ else:
30
+ raise ValueError("Root finding did not converge.")
31
+
32
+
33
+ def get_epsilon_for_err_rates(delta: float, alpha: float, beta: float):
34
+ """Derive epsilon from given FPR/FNR error rates and delta.
35
+
36
+ >>> round(get_epsilon_for_err_rates(0.001, 0.001, 0.8), 3)
37
+ 5.293
38
+ """
39
+ epsilon1 = np.log((1 - delta - alpha) / beta)
40
+ epsilon2 = np.log((1 - delta - beta) / alpha)
41
+ return max(epsilon1, epsilon2, 0.0)
42
+
43
+
44
+ def get_delta_for_err_rates(epsilon: float, alpha: float, beta: float):
45
+ """Derive delta from given FPR/FNR error rates and epsilon.
46
+
47
+ >>> round(get_delta_for_err_rates(1.0, 0.001, 0.8), 3)
48
+ 0.197
49
+ """
50
+ delta1 = (1 - beta) - np.exp(epsilon) * alpha
51
+ delta2 = (1 - alpha) - np.exp(epsilon) * beta
52
+ return max(delta1, delta2, 0)
53
+
54
+
55
+ def get_err_rate_for_epsilon_delta(epsilon: float, delta: float, alpha: float):
56
+ """Derive the error rate (e.g., FNR) for a given epsilon, delta, and the other error rate (e.g., FPR).
57
+
58
+ >>> round(get_err_rate_for_epsilon_delta(1.0, 0.001, 0.8), 3)
59
+ 0.073
60
+ """
61
+ # See, e.g., Eq. 5 in https://arxiv.org/abs/1905.02383
62
+ return max(
63
+ 0,
64
+ 1 - delta - np.exp(epsilon) * alpha,
65
+ np.exp(-epsilon) * (1 - delta - alpha),
66
+ )