Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 38 additions & 23 deletions jax_privacy/auditing.py
Original file line number Diff line number Diff line change
Expand Up @@ -901,30 +901,21 @@ def epsilon_from_gdp(
if eps_tol <= 0:
raise ValueError(f'eps_tol must be positive, got {eps_tol}.')

n_pos = self._fn_counts[-1]
n_neg = self._tn_counts[-1]

n = len(self._fn_counts)

# Apply Bonferroni correction over 2 * n hypotheses.
fnr_ubs = _clopper_pearson_upper(
self._fn_counts, n_pos, significance / (2 * n)
# Swapping in/out scores audits the D', D direction with its own frontier.
_, reverse_tn_counts, reverse_fn_counts = _get_tn_fn_counts(
self._out_canary_scores, self._in_canary_scores
)
bound_significance = significance / (
2 * (len(self._fn_counts) + len(reverse_fn_counts))
)
max_mu = max(
self._mu_from_gdp_counts(
self._fn_counts, self._tn_counts, bound_significance, delta
),
self._mu_from_gdp_counts(
reverse_fn_counts, reverse_tn_counts, bound_significance, delta
),
)
fp_counts = n_neg - self._tn_counts
fpr_ubs = _clopper_pearson_upper(fp_counts, n_neg, significance / (2 * n))

bounds = np.stack([fpr_ubs, fnr_ubs], axis=1)

# Filter any thresholds where TNR or TPR is too small.
bounds = bounds[np.max(bounds, axis=1) < 1 - delta]
if not bounds.size:
return 0

# Eq. 6 in https://arxiv.org/abs/1905.02383. If FPR + FNR is too large,
# the bound still holds in reverse (by switching D and D'), which has the
# effect of making mu from Eq. 6 negative. Hence we look for the maximum
# absolute value of mu.
max_mu = np.max(np.abs(_norm.isf(bounds[:, 0]) - _norm.ppf(bounds[:, 1])))
if max_mu == 0:
return 0

Expand All @@ -944,6 +935,30 @@ def delta_gap(eps):

return scipy.optimize.brentq(delta_gap, eps_lb, eps_ub, xtol=eps_tol)

@staticmethod
def _mu_from_gdp_counts(
fn_counts: np.ndarray,
tn_counts: np.ndarray,
bound_significance: float,
delta: float,
) -> float:
"""Calculates a one-sided GDP lower bound on mu from one frontier."""
n_pos = fn_counts[-1]
n_neg = tn_counts[-1]

fnr_ubs = _clopper_pearson_upper(fn_counts, n_pos, bound_significance)
fpr_ubs = _clopper_pearson_upper(
n_neg - tn_counts, n_neg, bound_significance
)

keep = np.maximum(fpr_ubs, fnr_ubs) < 1 - delta
if not np.any(keep):
return 0.0

# Eq. 6 in https://arxiv.org/abs/1905.02383 gives
# mu >= isf(FPR) - ppf(FNR). Negative values are no evidence.
return max(0.0, np.max(_norm.isf(fpr_ubs[keep]) - _norm.ppf(fnr_ubs[keep])))

def _epsilon_one_run_all_thresholds(
self,
significance: float,
Expand Down
48 changes: 48 additions & 0 deletions tests/auditing_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,54 @@ def test_epsilon_from_gdp_tight(self, mu, out_samples_ratio):
true_eps = dp_accounting.get_epsilon_gaussian(1 / mu, delta)
np.testing.assert_allclose(eps, true_eps, rtol=0.05)

def test_epsilon_from_gdp_null_is_zero(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, the change in this PR means that if the in_canary_scores have smaller values on average than out_canary_scores, the returned epsilon is zero. If so, we should test that case like:

in_canary_scores = rng.normal(-1, 1, m)
out_canary_scores = rng.normal(0, 1, m)

I would suggest that instead of this test and test_epsilon_from_gdp_small_sample_null_is_zero below, we have a single parameterized product test that looks at
(large sample, small sample) x (mu 0, mu negative)

rng = np.random.default_rng(seed=0xBAD5EED)
significance = 0.05
delta = 1e-5
m = 5000
in_canary_scores = rng.normal(0, 1, m)
out_canary_scores = rng.normal(0, 1, m)
auditor = auditing.CanaryScoreAuditor(in_canary_scores, out_canary_scores)

eps = auditor.epsilon_from_gdp(significance, delta)
self.assertLessEqual(eps, 0.1)

def test_epsilon_from_gdp_separated_is_positive(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is less specific than the earlier test [test_epsilon_from_gdp_tight]. Please remove it.

rng = np.random.default_rng(seed=0xBAD5EED)
significance = 0.05
delta = 1e-5
m = 5000
in_canary_scores = rng.normal(3.0, 1, m)
out_canary_scores = rng.normal(0, 1, m)
auditor = auditing.CanaryScoreAuditor(in_canary_scores, out_canary_scores)

eps = auditor.epsilon_from_gdp(significance, delta)
self.assertGreater(eps, 1.0)

def test_epsilon_from_gdp_reverse_separated_is_positive(self):
rng = np.random.default_rng(seed=0xBAD5EED)
significance = 0.05
delta = 1e-5
m = 5000
in_canary_scores = rng.normal(0.0, 1, m)
out_canary_scores = rng.normal(3.0, 1, m)
auditor = auditing.CanaryScoreAuditor(in_canary_scores, out_canary_scores)

eps = auditor.epsilon_from_gdp(significance, delta)
self.assertGreater(eps, 1.0)

def test_epsilon_from_gdp_small_sample_null_is_zero(self):
rng = np.random.default_rng(seed=0xC0FFEE)
significance = 0.05
delta = 1e-5
m = 50
in_canary_scores = rng.normal(0, 1, m)
out_canary_scores = rng.normal(0, 1, m)
auditor = auditing.CanaryScoreAuditor(in_canary_scores, out_canary_scores)

eps = auditor.epsilon_from_gdp(significance, delta)
self.assertLessEqual(eps, 0.1)

@parameterized.product(
quantiles=(0.025, 0.975, (0.025, 0.975), (0.025, 0.5, 0.975)),
bootstrap_type=('quantile', 'bias_correction', 'acceleration'),
Expand Down