diff --git a/python/dp_accounting/dp_accounting/__init__.py b/python/dp_accounting/dp_accounting/__init__.py index 39ec7595..16d547b9 100644 --- a/python/dp_accounting/dp_accounting/__init__.py +++ b/python/dp_accounting/dp_accounting/__init__.py @@ -20,6 +20,7 @@ from dp_accounting import pld from dp_accounting import privacy_accountant from dp_accounting import rdp +from dp_accounting.dp_event import ApproximateDpEvent from dp_accounting.dp_event import ComposedDpEvent from dp_accounting.dp_event import DiscreteGaussianDpEvent from dp_accounting.dp_event import DpEvent diff --git a/python/dp_accounting/dp_accounting/dp_event.py b/python/dp_accounting/dp_accounting/dp_event.py index 8fc876e8..92a52068 100644 --- a/python/dp_accounting/dp_accounting/dp_event.py +++ b/python/dp_accounting/dp_accounting/dp_event.py @@ -521,3 +521,28 @@ class TruncatedSubsampledGaussianDpEvent(DpEvent): sampling_probability: float truncated_batch_size: int noise_multiplier: float + + +@attr.s(frozen=True, slots=True, auto_attribs=True) +class ApproximateDpEvent(DpEvent): + """Represents a mechanism with an additional additive delta term. + + If the inner `event` satisfies (epsilon, delta_inner)-DP for some + (epsilon, delta_inner) pair, then this event satisfies + (epsilon, delta_inner + delta)-DP. The `delta` field captures an additional + probability of failure that is separate from the privacy loss distribution + of the inner mechanism. A canonical example is the Gaussian Thresholding + algorithm for partition selection, where the inner event is a Gaussian + mechanism and the extra delta accounts for the thresholding step. + + Accountants process this by composing the inner event normally and tracking + the extra delta additively. At query time, `get_epsilon(target_delta)` uses + `target_delta - sum(extra_deltas)` as the effective delta for the inner + events. + + Attributes: + event: The inner DpEvent whose privacy loss is tracked by the accountant. + delta: The additional delta term. Must be in [0, 1]. + """ + event: DpEvent + delta: float diff --git a/python/dp_accounting/dp_accounting/dp_event_test.py b/python/dp_accounting/dp_accounting/dp_event_test.py index 43c53a70..e64119fb 100644 --- a/python/dp_accounting/dp_accounting/dp_event_test.py +++ b/python/dp_accounting/dp_accounting/dp_event_test.py @@ -130,6 +130,17 @@ class DpEventTest(parameterized.TestCase): 1.0, ), ), + ( + 'approximate', + dp_event.ApproximateDpEvent(dp_event.GaussianDpEvent(1.0), 0.01), + ), + ( + 'approximate_composed', + dp_event.ComposedDpEvent([ + dp_event.ApproximateDpEvent(dp_event.GaussianDpEvent(1.0), 0.01), + dp_event.LaplaceDpEvent(1.0), + ]), + ), ) def test_to_from_named_tuple(self, event): named_tuple = event.to_named_tuple() diff --git a/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant.py b/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant.py index 0ea858e3..c1774d6e 100644 --- a/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant.py +++ b/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant.py @@ -62,6 +62,14 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, if result is not None: return result return None + elif isinstance(event, dp_event.ApproximateDpEvent): + # REVIEWER NOTE: The extra delta scales linearly with `count` via basic + # composition. If this event is inside SelfComposedDpEvent(event, T), + # then count = T, so we accumulate T * delta. This is the standard + # advanced composition bound for additive delta terms. + if do_compose: + self._extra_delta += count * event.delta + return self._maybe_compose(event.event, count, do_compose) elif isinstance(event, dp_event.EpsilonDeltaDpEvent): if do_compose: self._pld = self._pld.compose( @@ -195,15 +203,28 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, self._pld = self._pld.compose(eps_dp_pld) return None elif isinstance(event, dp_event.PoissonSampledDpEvent): - if isinstance(event.event, dp_event.GaussianDpEvent): + # REVIEWER NOTE: When ApproximateDpEvent wraps the sub-event of a + # PoissonSampledDpEvent, we strip it and track the extra delta + # *without* subsampling amplification. This is a conservative upper + # bound: the extra delta may represent a mechanism failure probability + # that is not reduced by subsampling. The inner event IS amplified by + # subsampling as usual. We only add the delta when the mechanism is + # actually invoked (sampling_probability > 0 and noise > 0). + inner_event = event.event + approximate_delta = 0.0 + if isinstance(inner_event, dp_event.ApproximateDpEvent): + approximate_delta = inner_event.delta + inner_event = inner_event.event + if isinstance(inner_event, dp_event.GaussianDpEvent): if do_compose: if event.sampling_probability == 0: pass - elif event.event.noise_multiplier == 0: + elif inner_event.noise_multiplier == 0: self._contains_non_dp_event = True else: + self._extra_delta += count * approximate_delta subsampled_gaussian_pld = PLD.from_gaussian_mechanism( - standard_deviation=event.event.noise_multiplier, + standard_deviation=inner_event.noise_multiplier, value_discretization_interval=self ._value_discretization_interval, sampling_prob=event.sampling_probability, @@ -211,7 +232,7 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, ).self_compose(count) self._pld = self._pld.compose(subsampled_gaussian_pld) return None - elif isinstance(event.event, dp_event.LaplaceDpEvent): + elif isinstance(inner_event, dp_event.LaplaceDpEvent): if self.neighboring_relation not in [ NeighborRel.ADD_OR_REMOVE_ONE, NeighborRel.REPLACE_SPECIAL ]: @@ -226,11 +247,12 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, if do_compose: if event.sampling_probability == 0: pass - elif event.event.noise_multiplier == 0: + elif inner_event.noise_multiplier == 0: self._contains_non_dp_event = True else: + self._extra_delta += count * approximate_delta subsampled_laplace_pld = PLD.from_laplace_mechanism( - parameter=event.event.noise_multiplier, + parameter=inner_event.noise_multiplier, value_discretization_interval=self ._value_discretization_interval, sampling_prob=event.sampling_probability).self_compose(count) @@ -241,7 +263,8 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, invalid_event=event, error_message=( 'Subevent of `PoissonSampledEvent` must be either ' - f'`GaussianDpEvent` or `LaplaceDpEvent`. Found {event.event}.' + '`GaussianDpEvent` or `LaplaceDpEvent` (optionally wrapped ' + f'in `ApproximateDpEvent`). Found {inner_event}.' ), ) elif isinstance(event, dp_event.TruncatedSubsampledGaussianDpEvent): @@ -275,12 +298,18 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, def get_epsilon(self, target_delta: float) -> float: if self._contains_non_dp_event: return math.inf - return self._pld.get_epsilon_for_delta(target_delta) + # REVIEWER NOTE: Subtract the accumulated extra delta from + # ApproximateDpEvents. If the remaining delta budget is negative, + # the approximate terms alone exhaust the budget. + effective_delta = target_delta - self._extra_delta + if effective_delta < 0: + return math.inf + return self._pld.get_epsilon_for_delta(effective_delta) def get_delta(self, target_epsilon: float) -> float: if self._contains_non_dp_event: return 1 - return self._pld.get_delta_for_epsilon(target_epsilon) # pytype: disable=bad-return-type + return self._pld.get_delta_for_epsilon(target_epsilon) + self._extra_delta # pytype: disable=bad-return-type def get_true_positive_rates( self, diff --git a/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant_test.py b/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant_test.py index 20922ad9..a7202722 100644 --- a/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant_test.py +++ b/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant_test.py @@ -25,56 +25,91 @@ from dp_accounting.pld import pld_privacy_accountant -class PldPrivacyAccountantTest(privacy_accountant_test.PrivacyAccountantTest, - parameterized.TestCase): +class PldPrivacyAccountantTest( + privacy_accountant_test.PrivacyAccountantTest, parameterized.TestCase +): def _make_test_accountants(self): return [ pld_privacy_accountant.PLDAccountant( - pld_privacy_accountant.NeighborRel.ADD_OR_REMOVE_ONE), + pld_privacy_accountant.NeighborRel.ADD_OR_REMOVE_ONE + ), pld_privacy_accountant.PLDAccountant( - pld_privacy_accountant.NeighborRel.REPLACE_ONE), + pld_privacy_accountant.NeighborRel.REPLACE_ONE + ), pld_privacy_accountant.PLDAccountant( - pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL), + pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL + ), ] @parameterized.parameters( - (dp_event.RandomizedResponseDpEvent(0.1, 3), - pld_privacy_accountant.NeighborRel.ADD_OR_REMOVE_ONE, - 'neighboring_relation must be `REPLACE_ONE` or `REPLACE_SPECIAL`'), - (dp_event.LaplaceDpEvent(1.0), - pld_privacy_accountant.NeighborRel.REPLACE_ONE, - 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or `REPLACE_SPECIAL`'), - (dp_event.PoissonSampledDpEvent(0.1, dp_event.LaplaceDpEvent(1.0)), - pld_privacy_accountant.NeighborRel.REPLACE_ONE, - 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or `REPLACE_SPECIAL`'), - (dp_event.DiscreteLaplaceDpEvent(0.1, 1), - pld_privacy_accountant.NeighborRel.REPLACE_ONE, - 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or `REPLACE_SPECIAL`'), - (dp_event.MixtureOfGaussiansDpEvent(1.0, [0.0, 1.0], [0.5, 0.5]), - pld_privacy_accountant.NeighborRel.REPLACE_ONE, - 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or `REPLACE_SPECIAL`'), + ( + dp_event.RandomizedResponseDpEvent(0.1, 3), + pld_privacy_accountant.NeighborRel.ADD_OR_REMOVE_ONE, + 'neighboring_relation must be `REPLACE_ONE` or `REPLACE_SPECIAL`', + ), + ( + dp_event.LaplaceDpEvent(1.0), + pld_privacy_accountant.NeighborRel.REPLACE_ONE, + ( + 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or' + ' `REPLACE_SPECIAL`' + ), + ), + ( + dp_event.PoissonSampledDpEvent(0.1, dp_event.LaplaceDpEvent(1.0)), + pld_privacy_accountant.NeighborRel.REPLACE_ONE, + ( + 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or' + ' `REPLACE_SPECIAL`' + ), + ), + ( + dp_event.DiscreteLaplaceDpEvent(0.1, 1), + pld_privacy_accountant.NeighborRel.REPLACE_ONE, + ( + 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or' + ' `REPLACE_SPECIAL`' + ), + ), + ( + dp_event.MixtureOfGaussiansDpEvent(1.0, [0.0, 1.0], [0.5, 0.5]), + pld_privacy_accountant.NeighborRel.REPLACE_ONE, + ( + 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or' + ' `REPLACE_SPECIAL`' + ), + ), ) def test_composition_errors_for_neighboring_relation( - self, event, neighboring_relation, error_msg): + self, event, neighboring_relation, error_msg + ): pld_accountant = pld_privacy_accountant.PLDAccountant(neighboring_relation) - with self.assertRaisesRegex(privacy_accountant.UnsupportedEventError, - error_msg): + with self.assertRaisesRegex( + privacy_accountant.UnsupportedEventError, error_msg + ): pld_accountant.compose(event) @parameterized.named_parameters( - ('replace_one', - pld_privacy_accountant.NeighborRel.REPLACE_ONE, True), - ('replace_special', - pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL, True), - ('add_or_remove_one', - pld_privacy_accountant.NeighborRel.ADD_OR_REMOVE_ONE, False), + ('replace_one', pld_privacy_accountant.NeighborRel.REPLACE_ONE, True), + ( + 'replace_special', + pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL, + True, + ), + ( + 'add_or_remove_one', + pld_privacy_accountant.NeighborRel.ADD_OR_REMOVE_ONE, + False, + ), ) def test_supports_randomized_response( - self, neighboring_relation, supports_composition): + self, neighboring_relation, supports_composition + ): pld_accountant = pld_privacy_accountant.PLDAccountant(neighboring_relation) - event = dp_event.RandomizedResponseDpEvent(noise_parameter=0.1, - num_buckets=3) + event = dp_event.RandomizedResponseDpEvent( + noise_parameter=0.1, num_buckets=3 + ) self.assertEqual(pld_accountant.supports(event), supports_composition) @parameterized.named_parameters( @@ -89,25 +124,39 @@ def test_randomized_response_with_single_bucket(self, neighboring_relation): def test_randomized_response_with_zero_noise_parameter(self): accountant = pld_privacy_accountant.PLDAccountant( - pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL) + pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL + ) accountant.compose(dp_event.RandomizedResponseDpEvent(0.0, 3)) self.assertEqual(accountant.get_delta(1.0), 1) self.assertEqual(accountant.get_epsilon(0.01), math.inf) @parameterized.parameters( - (pld_privacy_accountant.NeighborRel.REPLACE_ONE, - 4 / (3 + math.exp(1)), 4, 1.0), - (pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL, - (4 - math.exp(1)) / 3, 4, 1.0), - (pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL, - math.exp(-1), 2, 1.0), + ( + pld_privacy_accountant.NeighborRel.REPLACE_ONE, + 4 / (3 + math.exp(1)), + 4, + 1.0, + ), + ( + pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL, + (4 - math.exp(1)) / 3, + 4, + 1.0, + ), + ( + pld_privacy_accountant.NeighborRel.REPLACE_SPECIAL, + math.exp(-1), + 2, + 1.0, + ), ) def test_randomized_response( - self, neighboring_relation, - noise_parameter, num_buckets, expected_epsilon): + self, neighboring_relation, noise_parameter, num_buckets, expected_epsilon + ): accountant = pld_privacy_accountant.PLDAccountant(neighboring_relation) accountant.compose( - dp_event.RandomizedResponseDpEvent(noise_parameter, num_buckets)) + dp_event.RandomizedResponseDpEvent(noise_parameter, num_buckets) + ) self.assertAlmostEqual(accountant.get_delta(expected_epsilon), 0.0) @parameterized.product( @@ -119,8 +168,9 @@ def test_randomized_response( ), dp_event.PoissonSampledDpEvent(0.1, dp_event.GaussianDpEvent(1.0)), dp_event.ComposedDpEvent([ - dp_event.PoissonSampledDpEvent(0.1, - dp_event.GaussianDpEvent(1.0)), + dp_event.PoissonSampledDpEvent( + 0.1, dp_event.GaussianDpEvent(1.0) + ), dp_event.GaussianDpEvent(2.0), ]), ], @@ -136,7 +186,8 @@ def test_supports_gaussian(self, event, neighboring_relation): def test_poisson_subsampling_laplace_not_supported_for_replace_one(self): pld_accountant = pld_privacy_accountant.PLDAccountant( - pld_privacy_accountant.NeighborRel.REPLACE_ONE) + pld_privacy_accountant.NeighborRel.REPLACE_ONE + ) event = dp_event.PoissonSampledDpEvent(0.1, dp_event.LaplaceDpEvent(1.0)) self.assertFalse(pld_accountant.supports(event)) @@ -146,11 +197,12 @@ def test_poisson_subsampling_laplace_not_supported_for_replace_one(self): (pld_privacy_accountant.NeighborRel.ADD_OR_REMOVE_ONE, False), ) def test_supports_subsampled_gaussian_and_rr_composition( - self, neighboring_relation, supports_composition): + self, neighboring_relation, supports_composition + ): pld_accountant = pld_privacy_accountant.PLDAccountant(neighboring_relation) event = dp_event.ComposedDpEvent([ dp_event.PoissonSampledDpEvent(0.1, dp_event.GaussianDpEvent(1.0)), - dp_event.RandomizedResponseDpEvent(noise_parameter=0.1, num_buckets=3) + dp_event.RandomizedResponseDpEvent(noise_parameter=0.1, num_buckets=3), ]) self.assertEqual(pld_accountant.supports(event), supports_composition) @@ -248,9 +300,11 @@ def test_gaussian_basic(self): exact_tpr_at_0_1_fpr = 0.38914 exact_mu = 1 self.assertAlmostEqual( - accountant.get_delta(exact_epsilon), exact_delta, delta=1e-3) + accountant.get_delta(exact_epsilon), exact_delta, delta=1e-3 + ) self.assertAlmostEqual( - accountant.get_epsilon(exact_delta), exact_epsilon, delta=1e-3) + accountant.get_epsilon(exact_delta), exact_epsilon, delta=1e-3 + ) self.assertAlmostEqual( accountant.get_true_positive_rates(0.1), exact_tpr_at_0_1_fpr, @@ -262,7 +316,8 @@ def test_gaussian_basic(self): def test_poisson_subsampled_gaussian(self): subsampled_gaussian_event = dp_event.PoissonSampledDpEvent( - 0.2, dp_event.GaussianDpEvent(noise_multiplier=0.5)) + 0.2, dp_event.GaussianDpEvent(noise_multiplier=0.5) + ) accountant = pld_privacy_accountant.PLDAccountant() accountant.compose(subsampled_gaussian_event, 1) accountant.compose(subsampled_gaussian_event, 2) @@ -270,22 +325,27 @@ def test_poisson_subsampled_gaussian(self): exact_epsilon = 1 expected_delta = 0.15594 self.assertAlmostEqual( - accountant.get_delta(exact_epsilon), expected_delta, delta=1e-3) + accountant.get_delta(exact_epsilon), expected_delta, delta=1e-3 + ) self.assertAlmostEqual( - accountant.get_epsilon(expected_delta), exact_epsilon, delta=1e-3) + accountant.get_epsilon(expected_delta), exact_epsilon, delta=1e-3 + ) def test_self_composed_subsampled_gaussian(self): event = dp_event.SelfComposedDpEvent( - dp_event.PoissonSampledDpEvent(0.2, dp_event.GaussianDpEvent(0.5)), 3) + dp_event.PoissonSampledDpEvent(0.2, dp_event.GaussianDpEvent(0.5)), 3 + ) accountant = pld_privacy_accountant.PLDAccountant() accountant.compose(event) exact_epsilon = 1 expected_delta = 0.15594 self.assertAlmostEqual( - accountant.get_delta(exact_epsilon), expected_delta, delta=1e-3) + accountant.get_delta(exact_epsilon), expected_delta, delta=1e-3 + ) self.assertAlmostEqual( - accountant.get_epsilon(expected_delta), exact_epsilon, delta=1e-3) + accountant.get_epsilon(expected_delta), exact_epsilon, delta=1e-3 + ) def test_laplace_basic(self): first_laplace_event = dp_event.LaplaceDpEvent(noise_multiplier=1) @@ -298,13 +358,16 @@ def test_laplace_basic(self): expected_delta = 1e-14 # expected delta is not 0 due to truncation in # self composition self.assertAlmostEqual( - accountant.get_delta(expected_epsilon), expected_delta, delta=1e-6) + accountant.get_delta(expected_epsilon), expected_delta, delta=1e-6 + ) self.assertAlmostEqual( - accountant.get_epsilon(expected_delta), expected_epsilon, delta=1e-6) + accountant.get_epsilon(expected_delta), expected_epsilon, delta=1e-6 + ) def test_poisson_subsampled_laplace(self): subsampled_laplace_event = dp_event.PoissonSampledDpEvent( - 0.2, dp_event.LaplaceDpEvent(noise_multiplier=0.5)) + 0.2, dp_event.LaplaceDpEvent(noise_multiplier=0.5) + ) accountant = pld_privacy_accountant.PLDAccountant() accountant.compose(subsampled_laplace_event, 1) accountant.compose(subsampled_laplace_event, 2) @@ -313,9 +376,11 @@ def test_poisson_subsampled_laplace(self): expected_delta = 1e-14 # expected delta is not 0 due to truncation in # self composition self.assertAlmostEqual( - accountant.get_delta(exact_epsilon), expected_delta, delta=1e-6) + accountant.get_delta(exact_epsilon), expected_delta, delta=1e-6 + ) self.assertAlmostEqual( - accountant.get_epsilon(expected_delta), exact_epsilon, delta=1e-3) + accountant.get_epsilon(expected_delta), exact_epsilon, delta=1e-3 + ) def test_discrete_laplace_basic(self): first_discrete_laplace_event = dp_event.DiscreteLaplaceDpEvent( @@ -330,9 +395,11 @@ def test_discrete_laplace_basic(self): expected_epsilon = 4 expected_delta = 0.0 self.assertAlmostEqual( - accountant.get_delta(expected_epsilon), expected_delta, delta=1e-6) + accountant.get_delta(expected_epsilon), expected_delta, delta=1e-6 + ) self.assertAlmostEqual( - accountant.get_epsilon(expected_delta), expected_epsilon, delta=1e-6) + accountant.get_epsilon(expected_delta), expected_epsilon, delta=1e-6 + ) def test_mixture_of_gaussians_basic(self): first_mog_event = dp_event.MixtureOfGaussiansDpEvent( @@ -393,6 +460,93 @@ def test_contains_non_dp_event(self): self.assertEqual(accountant.get_true_positive_rates(0.1), 1) self.assertEqual(accountant.get_gdp_parameter_estimate(), math.inf) + def test_approximate_dp_event_basic(self): + """ApproximateDpEvent should shift the delta by the extra delta.""" + gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=1.0) + approximate_event = dp_event.ApproximateDpEvent(gaussian_event, delta=0.01) + accountant = pld_privacy_accountant.PLDAccountant() + accountant.compose(approximate_event) + + # Compare against a bare Gaussian with a shifted delta. + reference_accountant = pld_privacy_accountant.PLDAccountant() + reference_accountant.compose(gaussian_event) + target_delta = 1e-5 + expected_epsilon = reference_accountant.get_epsilon(target_delta - 0.01) + self.assertAlmostEqual( + accountant.get_epsilon(target_delta), expected_epsilon, places=6 + ) + + def test_approximate_dp_event_get_delta(self): + """get_delta should add the extra delta to the inner delta.""" + gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=1.0) + approximate_event = dp_event.ApproximateDpEvent(gaussian_event, delta=0.01) + accountant = pld_privacy_accountant.PLDAccountant() + accountant.compose(approximate_event) + + reference_accountant = pld_privacy_accountant.PLDAccountant() + reference_accountant.compose(gaussian_event) + target_epsilon = 1.0 + expected_delta = reference_accountant.get_delta(target_epsilon) + 0.01 + self.assertAlmostEqual( + accountant.get_delta(target_epsilon), expected_delta, places=6 + ) + + def test_approximate_dp_event_self_composed(self): + """Self-composition should multiply the extra delta by count.""" + gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=1.0) + event = dp_event.SelfComposedDpEvent( + dp_event.ApproximateDpEvent(gaussian_event, delta=0.001), 10 + ) + accountant = pld_privacy_accountant.PLDAccountant() + accountant.compose(event) + self.assertAlmostEqual(accountant._extra_delta, 0.01, places=10) + + def test_approximate_dp_event_exhausted_budget(self): + """If extra delta > target_delta, epsilon should be inf.""" + event = dp_event.ApproximateDpEvent( + dp_event.GaussianDpEvent(noise_multiplier=1.0), delta=0.1 + ) + accountant = pld_privacy_accountant.PLDAccountant() + accountant.compose(event) + # target_delta < extra_delta, so budget is exhausted. + self.assertEqual(accountant.get_epsilon(0.05), math.inf) + + def test_approximate_dp_event_poisson_subsampled(self): + """ApproximateDpEvent inside PoissonSampled preserves delta unamplified.""" + gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=1.0) + event = dp_event.PoissonSampledDpEvent( + 0.1, dp_event.ApproximateDpEvent(gaussian_event, delta=0.01) + ) + accountant = pld_privacy_accountant.PLDAccountant() + accountant.compose(event) + + # Inner event should be amplified normally. + reference_accountant = pld_privacy_accountant.PLDAccountant() + reference_accountant.compose( + dp_event.PoissonSampledDpEvent(0.1, gaussian_event) + ) + target_delta = 1e-5 + expected_epsilon = reference_accountant.get_epsilon(target_delta - 0.01) + self.assertAlmostEqual( + accountant.get_epsilon(target_delta), expected_epsilon, places=6 + ) + + def test_approximate_dp_event_supports(self): + """ApproximateDpEvent wrapping a supported event should be supported.""" + accountant = pld_privacy_accountant.PLDAccountant() + event = dp_event.ApproximateDpEvent( + dp_event.GaussianDpEvent(1.0), delta=0.01 + ) + self.assertTrue(accountant.supports(event)) + + def test_approximate_dp_event_unsupported_inner(self): + """ApproximateDpEvent wrapping an unsupported event should be unsupported.""" + accountant = pld_privacy_accountant.PLDAccountant() + event = dp_event.ApproximateDpEvent( + dp_event.UnsupportedDpEvent(), delta=0.01 + ) + self.assertFalse(accountant.supports(event)) + if __name__ == '__main__': absltest.main() diff --git a/python/dp_accounting/dp_accounting/privacy_accountant.py b/python/dp_accounting/dp_accounting/privacy_accountant.py index 2ea9d923..e63823a2 100644 --- a/python/dp_accounting/dp_accounting/privacy_accountant.py +++ b/python/dp_accounting/dp_accounting/privacy_accountant.py @@ -42,6 +42,7 @@ class PrivacyAccountant(metaclass=abc.ABCMeta): def __init__(self, neighboring_relation: NeighboringRelation): self._neighboring_relation = neighboring_relation self._ledger = dp_event_builder.DpEventBuilder() + self._extra_delta = 0.0 @property def neighboring_relation(self) -> NeighboringRelation: diff --git a/python/dp_accounting/dp_accounting/privacy_accountant_test.py b/python/dp_accounting/dp_accounting/privacy_accountant_test.py index c2df6501..a97021e2 100644 --- a/python/dp_accounting/dp_accounting/privacy_accountant_test.py +++ b/python/dp_accounting/dp_accounting/privacy_accountant_test.py @@ -35,7 +35,8 @@ class UnknownDpEvent(dp_event.DpEvent): class PrivacyAccountantTest(parameterized.TestCase): def _make_test_accountants( - self) -> Collection[privacy_accountant.PrivacyAccountant]: + self, + ) -> Collection[privacy_accountant.PrivacyAccountant]: """Makes a list of accountants to test. Subclasses should define this to return a list of accountants to be tested. @@ -121,3 +122,12 @@ def test_non_private(self, non_private_event): except NotImplementedError: # Implementing `get_delta` is optional. pass + + def test_approximate_noop(self): + for accountant in self._make_test_accountants(): + event = dp_event.ApproximateDpEvent(dp_event.NoOpDpEvent(), delta=0.0) + self.assertTrue(accountant.supports(event)) + accountant.compose(event) + self.assertEqual(accountant.get_epsilon(1e-12), 0) + self.assertEqual(accountant.get_epsilon(0), 0) + self.assertEqual(accountant.get_epsilon(1), 0) diff --git a/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant.py b/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant.py index 176762dc..03f2370d 100644 --- a/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant.py +++ b/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant.py @@ -1028,6 +1028,14 @@ def _maybe_compose( if result is not None: return result return None + elif isinstance(event, dp_event.ApproximateDpEvent): + # REVIEWER NOTE: The extra delta scales linearly with `count` via basic + # composition. If this event is inside SelfComposedDpEvent(event, T), + # then count = T, so we accumulate T * delta. This is the standard + # advanced composition bound for additive delta terms. + if do_compose: + self._extra_delta += count * event.delta + return self._maybe_compose(event.event, count, do_compose) elif isinstance(event, dp_event.GaussianDpEvent): if do_compose: self._rdp += count * _compute_rdp_poisson_subsampled_gaussian( @@ -1072,8 +1080,19 @@ def _maybe_compose( return CompositionErrorDetails( invalid_event=event, error_message=error_msg ) + # REVIEWER NOTE: When ApproximateDpEvent wraps the sub-event of a + # PoissonSampledDpEvent, we strip it and track the extra delta + # *without* subsampling amplification. This is a conservative upper + # bound: the extra delta may represent a mechanism failure probability + # that is not reduced by subsampling. The inner event IS amplified by + # subsampling as usual. + inner_event = event.event + approximate_delta = 0.0 + if isinstance(inner_event, dp_event.ApproximateDpEvent): + approximate_delta = inner_event.delta + inner_event = inner_event.event sigma_or_bad_event = _effective_gaussian_noise_multiplier( - event.event, accept_zcdp=True + inner_event, accept_zcdp=True ) if isinstance(sigma_or_bad_event, dp_event.DpEvent): return CompositionErrorDetails( @@ -1082,10 +1101,12 @@ def _maybe_compose( 'Subevent of `PoissonSampledDpEvent` must be a' ' `GaussianDpEvent` or a nested structure of `ComposedDpEvent`' ' and/or `SelfComposedDpEvent` bottoming out in' - f' `GaussianDpEvent`s. Found subevent {sigma_or_bad_event}.' + ' `GaussianDpEvent`s (optionally wrapped in' + f' `ApproximateDpEvent`). Found subevent {sigma_or_bad_event}.' ), ) if do_compose: + self._extra_delta += count * approximate_delta self._rdp += count * _compute_rdp_poisson_subsampled_gaussian( q=event.sampling_probability, noise_multiplier=sigma_or_bad_event, @@ -1201,7 +1222,13 @@ def get_epsilon_and_optimal_order( A tuple containing the current epsilon, accounting for all composed `DpEvent`s, and the optimal order. """ - return compute_epsilon(self._orders, self._rdp, target_delta) + # REVIEWER NOTE: Subtract accumulated extra delta from ApproximateDpEvents. + # If the remaining delta budget is negative, the approximate terms + # alone exhaust the budget. + effective_delta = target_delta - self._extra_delta + if effective_delta < 0: + return float('inf'), 0 + return compute_epsilon(self._orders, self._rdp, effective_delta) def get_epsilon(self, target_delta: float) -> float: """Returns the current epsilon. @@ -1212,7 +1239,7 @@ def get_epsilon(self, target_delta: float) -> float: Returns: The current epsilon, accounting for all composed `DpEvent`s. """ - return compute_epsilon(self._orders, self._rdp, target_delta)[0] + return self.get_epsilon_and_optimal_order(target_delta)[0] def get_delta_and_optimal_order( self, target_epsilon: float @@ -1226,7 +1253,8 @@ def get_delta_and_optimal_order( A tuple containing the current delta, accounting for all composed `DpEvent`s, and the optimal order. """ - return compute_delta(self._orders, self._rdp, target_epsilon) + delta, order = compute_delta(self._orders, self._rdp, target_epsilon) + return delta + self._extra_delta, order def get_delta(self, target_epsilon: float) -> float: """Returns the current delta. @@ -1237,7 +1265,7 @@ def get_delta(self, target_epsilon: float) -> float: Returns: The current delta, accounting for all composed `DpEvent`s. """ - return compute_delta(self._orders, self._rdp, target_epsilon)[0] + return self.get_delta_and_optimal_order(target_epsilon)[0] @property def rdp(self) -> np.ndarray: diff --git a/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant_test.py b/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant_test.py index 43365cca..c2f60f4c 100644 --- a/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant_test.py +++ b/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant_test.py @@ -1222,6 +1222,96 @@ def test_log_a_frac_early_termination(self): self.assertNotEmpty([l for l in log.output if 'failed to converge' in l]) self.assertIn(np.inf, accountant._rdp) + def test_approximate_dp_event_basic(self): + """ApproximateDpEvent should shift the delta by the extra delta.""" + gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=1.0) + approximate_event = dp_event.ApproximateDpEvent(gaussian_event, delta=0.01) + accountant = rdp_privacy_accountant.RdpAccountant() + accountant.compose(approximate_event) + + # Compare against a bare Gaussian with a shifted delta. + reference_accountant = rdp_privacy_accountant.RdpAccountant() + reference_accountant.compose(gaussian_event) + target_delta = 1e-5 + expected_epsilon = reference_accountant.get_epsilon(target_delta - 0.01) + self.assertAlmostEqual( + accountant.get_epsilon(target_delta), expected_epsilon, places=6 + ) + + def test_approximate_dp_event_get_delta(self): + """get_delta should add the extra delta to the inner delta.""" + gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=1.0) + approximate_event = dp_event.ApproximateDpEvent(gaussian_event, delta=0.01) + accountant = rdp_privacy_accountant.RdpAccountant() + accountant.compose(approximate_event) + + reference_accountant = rdp_privacy_accountant.RdpAccountant() + reference_accountant.compose(gaussian_event) + target_epsilon = 1.0 + expected_delta = reference_accountant.get_delta(target_epsilon) + 0.01 + self.assertAlmostEqual( + accountant.get_delta(target_epsilon), expected_delta, places=6 + ) + + def test_approximate_dp_event_self_composed(self): + """Self-composition should multiply the extra delta by count.""" + gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=1.0) + event = dp_event.SelfComposedDpEvent( + dp_event.ApproximateDpEvent(gaussian_event, delta=0.001), 10 + ) + accountant = rdp_privacy_accountant.RdpAccountant() + accountant.compose(event) + self.assertAlmostEqual(accountant._extra_delta, 0.01, places=10) + + def test_approximate_dp_event_exhausted_budget(self): + """If extra delta > target_delta, epsilon should be inf.""" + event = dp_event.ApproximateDpEvent( + dp_event.GaussianDpEvent(noise_multiplier=1.0), delta=0.1 + ) + accountant = rdp_privacy_accountant.RdpAccountant() + accountant.compose(event) + self.assertEqual(accountant.get_epsilon(0.05), float('inf')) + + def test_approximate_dp_event_poisson_subsampled(self): + """ApproximateDpEvent inside PoissonSampled preserves delta unamplified.""" + gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=1.0) + event = dp_event.PoissonSampledDpEvent( + 0.1, dp_event.ApproximateDpEvent(gaussian_event, delta=0.01) + ) + accountant = rdp_privacy_accountant.RdpAccountant() + accountant.compose(event) + + # Inner event should be amplified normally. + reference_accountant = rdp_privacy_accountant.RdpAccountant() + reference_accountant.compose( + dp_event.PoissonSampledDpEvent(0.1, gaussian_event) + ) + target_delta = 1e-5 + expected_epsilon = reference_accountant.get_epsilon(target_delta - 0.01) + self.assertAlmostEqual( + accountant.get_epsilon(target_delta), expected_epsilon, places=6 + ) + + def test_approximate_dp_event_supports(self): + """ApproximateDpEvent wrapping a supported event should be supported.""" + accountant = rdp_privacy_accountant.RdpAccountant() + event = dp_event.ApproximateDpEvent( + dp_event.GaussianDpEvent(1.0), delta=0.01 + ) + self.assertTrue(accountant.supports(event)) + + # Also inside Poisson. + poisson_event = dp_event.PoissonSampledDpEvent(0.1, event) + self.assertTrue(accountant.supports(poisson_event)) + + def test_approximate_dp_event_unsupported_inner(self): + """ApproximateDpEvent wrapping an unsupported event should be unsupported.""" + accountant = rdp_privacy_accountant.RdpAccountant() + event = dp_event.ApproximateDpEvent( + dp_event.UnsupportedDpEvent(), delta=0.01 + ) + self.assertFalse(accountant.supports(event)) + if __name__ == '__main__': absltest.main()