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 +7 -0
- riskcal-0.1.0/PKG-INFO +80 -0
- riskcal-0.1.0/README.md +63 -0
- riskcal-0.1.0/pyproject.toml +40 -0
- riskcal-0.1.0/riskcal/__init__.py +3 -0
- riskcal-0.1.0/riskcal/blackbox.py +266 -0
- riskcal-0.1.0/riskcal/pld.py +317 -0
- riskcal-0.1.0/riskcal/utils.py +66 -0
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
|
+
[](https://github.com/bogdan-kulynych/riskcal/actions/workflows/ci.yml)
|
|
20
|
+
[](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
|
+
|
riskcal-0.1.0/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
## riskcal
|
|
2
|
+
|
|
3
|
+
[](https://github.com/bogdan-kulynych/riskcal/actions/workflows/ci.yml)
|
|
4
|
+
[](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,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
|
+
)
|