diff --git a/python/dp_accounting/dp_accounting/__init__.py b/python/dp_accounting/dp_accounting/__init__.py index 39ec7595..cae28783 100644 --- a/python/dp_accounting/dp_accounting/__init__.py +++ b/python/dp_accounting/dp_accounting/__init__.py @@ -28,6 +28,7 @@ from dp_accounting.dp_event import LaplaceDpEvent from dp_accounting.dp_event import NonPrivateDpEvent from dp_accounting.dp_event import NoOpDpEvent +from dp_accounting.dp_event import PermuteAndFlipDpEvent from dp_accounting.dp_event import PoissonSampledDpEvent from dp_accounting.dp_event import RandomizedResponseDpEvent from dp_accounting.dp_event import SampledWithoutReplacementDpEvent diff --git a/python/dp_accounting/dp_accounting/dp_event.py b/python/dp_accounting/dp_accounting/dp_event.py index 8fc876e8..deb46515 100644 --- a/python/dp_accounting/dp_accounting/dp_event.py +++ b/python/dp_accounting/dp_accounting/dp_event.py @@ -256,6 +256,7 @@ class RandomizedResponseDpEvent(DpEvent): corresponding to the case where the mechanism outputs a bucket drawn uniformly at random from the k buckets regardless of the input bucket. """ + noise_parameter: float num_buckets: int @@ -268,6 +269,7 @@ class EpsilonDeltaDpEvent(DpEvent): characterize the privacy loss. If a more specific DpEvent that characterizes a mechanism is available, it should be preferred over this one. """ + epsilon: float delta: float @@ -280,6 +282,7 @@ class GaussianDpEvent(DpEvent): If the norms of the values are bounded ||v_i|| <= C, the noise_multiplier is defined as s / C. """ + noise_multiplier: float @@ -294,6 +297,7 @@ class LaplaceDpEvent(DpEvent): If the L_1 norm of the values are bounded ||v_i||_1 <= C, the noise_multiplier is defined as s / C. """ + noise_multiplier: float @@ -308,6 +312,7 @@ class DiscreteLaplaceDpEvent(DpEvent): integer value x. If the L_1 norm of the values are bounded ||v_i||_1 <= C, the sensitivity is `C`. """ + noise_parameter: float sensitivity: int @@ -349,6 +354,7 @@ class SelfComposedDpEvent(DpEvent): This is equivalent to `ComposedDpEvent` that contains a list of length `count` of identical copies of `event`. """ + event: DpEvent count: int @@ -360,6 +366,7 @@ class ComposedDpEvent(DpEvent): The composition may be adaptive, where the query producing each event depends on the results of prior queries. """ + events: List[DpEvent] @@ -371,6 +378,7 @@ class PoissonSampledDpEvent(DpEvent): probability `sampling_probability`. Then the `DpEvent` `event` is applied to the sample of records. """ + sampling_probability: float event: DpEvent @@ -384,6 +392,7 @@ class SampledWithReplacementDpEvent(DpEvent): `source_dataset_size`. Then the `DpEvent` `event` is applied to the sample of records. """ + source_dataset_size: int sample_size: int event: DpEvent @@ -397,6 +406,7 @@ class SampledWithoutReplacementDpEvent(DpEvent): set of possible samples of a source dataset of size `source_dataset_size`. Then the `DpEvent` `event` is applied to the sample of records. """ + source_dataset_size: int sample_size: int event: DpEvent @@ -420,6 +430,7 @@ class SingleEpochTreeAggregationDpEvent(DpEvent): step_counts: The number of steps in each tree. May be a scalar for a single tree. """ + noise_multiplier: float step_counts: Union[int, List[int]] @@ -439,6 +450,7 @@ class RepeatAndSelectDpEvent(DpEvent): mean: The mean number of repetitions. shape: The shape of the distribution of the number of repetitions. """ + event: DpEvent mean: float shape: float @@ -499,6 +511,28 @@ class ExponentialMechanismDpEvent(DpEvent): epsilon: float +@attr.s(frozen=True, slots=True, auto_attribs=True) +class PermuteAndFlipDpEvent(DpEvent): + """Represents an application of the permute-and-flip mechanism. + + See https://arxiv.org/abs/2010.12603 (McKenna & Sheldon, 2020) for details. + + Unlike the exponential mechanism which is epsilon-bounded range, the + permute-and-flip mechanism satisfies standard epsilon-DP. Its privacy loss + distribution is identical to that of the Laplace mechanism with parameter + 1/epsilon. A dedicated event type is provided for API clarity and to allow + future tighter analyses. + + For RDP accounting, we use the tight Laplace RDP formula (Mironov, 2017). + For PLD accounting, we use the Laplace privacy loss distribution. + + Attributes: + epsilon: The epsilon parameter of the permute-and-flip mechanism. + """ + + epsilon: float + + @attr.s(frozen=True, slots=True, auto_attribs=True) class TruncatedSubsampledGaussianDpEvent(DpEvent): """Represents the Gaussian mechanism with truncated Poisson sampling. diff --git a/python/dp_accounting/dp_accounting/dp_event_test.py b/python/dp_accounting/dp_accounting/dp_event_test.py index 43c53a70..07266de3 100644 --- a/python/dp_accounting/dp_accounting/dp_event_test.py +++ b/python/dp_accounting/dp_accounting/dp_event_test.py @@ -117,6 +117,10 @@ class DpEventTest(parameterized.TestCase): 'exponential_mechanism', dp_event.ExponentialMechanismDpEvent(1.0), ), + ( + 'permute_and_flip', + dp_event.PermuteAndFlipDpEvent(1.0), + ), ( 'zcdp', dp_event.ZCDpEvent(1.0, 2.0), 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..ff6c6fc7 100644 --- a/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant.py +++ b/python/dp_accounting/dp_accounting/pld/pld_privacy_accountant.py @@ -24,7 +24,6 @@ from dp_accounting.pld import common from dp_accounting.pld import privacy_loss_distribution - NeighborRel = privacy_accountant.NeighboringRelation CompositionErrorDetails = ( privacy_accountant.PrivacyAccountant.CompositionErrorDetails @@ -43,11 +42,13 @@ def __init__( super().__init__(neighboring_relation) self._contains_non_dp_event = False self._pld = PLD.identity( - value_discretization_interval=value_discretization_interval) + value_discretization_interval=value_discretization_interval + ) self._value_discretization_interval = value_discretization_interval - def _maybe_compose(self, event: dp_event.DpEvent, count: int, - do_compose: bool) -> Optional[CompositionErrorDetails]: + def _maybe_compose( + self, event: dp_event.DpEvent, count: int, do_compose: bool + ) -> Optional[CompositionErrorDetails]: if isinstance(event, dp_event.NoOpDpEvent): return None elif isinstance(event, dp_event.NonPrivateDpEvent): @@ -74,14 +75,18 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, ) return None elif isinstance(event, dp_event.RandomizedResponseDpEvent): - if self.neighboring_relation not in [NeighborRel.REPLACE_ONE, - NeighborRel.REPLACE_SPECIAL]: + if self.neighboring_relation not in [ + NeighborRel.REPLACE_ONE, + NeighborRel.REPLACE_SPECIAL, + ]: error_msg = ( 'neighboring_relation must be `REPLACE_ONE` or ' '`REPLACE_SPECIAL` for `RandomizedResponseDpEvent`. Found ' - f'{self._neighboring_relation}.') + f'{self._neighboring_relation}.' + ) return CompositionErrorDetails( - invalid_event=event, error_message=error_msg) + invalid_event=event, error_message=error_msg + ) if do_compose: if event.num_buckets == 1: # This is a NoOp event, even when noise_parameter is zero. @@ -105,12 +110,14 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, gaussian_pld = PLD.from_gaussian_mechanism( standard_deviation=event.noise_multiplier / math.sqrt(count), value_discretization_interval=self._value_discretization_interval, - neighboring_relation=self.neighboring_relation) + neighboring_relation=self.neighboring_relation, + ) self._pld = self._pld.compose(gaussian_pld) return None elif isinstance(event, dp_event.LaplaceDpEvent): if self.neighboring_relation not in [ - NeighborRel.ADD_OR_REMOVE_ONE, NeighborRel.REPLACE_SPECIAL + NeighborRel.ADD_OR_REMOVE_ONE, + NeighborRel.REPLACE_SPECIAL, ]: return CompositionErrorDetails( invalid_event=event, @@ -126,13 +133,14 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, else: laplace_pld = PLD.from_laplace_mechanism( parameter=event.noise_multiplier, - value_discretization_interval=self._value_discretization_interval + value_discretization_interval=self._value_discretization_interval, ).self_compose(count) self._pld = self._pld.compose(laplace_pld) return None elif isinstance(event, dp_event.DiscreteLaplaceDpEvent): if self.neighboring_relation not in [ - NeighborRel.ADD_OR_REMOVE_ONE, NeighborRel.REPLACE_SPECIAL + NeighborRel.ADD_OR_REMOVE_ONE, + NeighborRel.REPLACE_SPECIAL, ]: return CompositionErrorDetails( invalid_event=event, @@ -149,13 +157,14 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, discrete_laplace_pld = PLD.from_discrete_laplace_mechanism( parameter=event.noise_parameter, sensitivity=event.sensitivity, - value_discretization_interval=self._value_discretization_interval + value_discretization_interval=self._value_discretization_interval, ).self_compose(count) self._pld = self._pld.compose(discrete_laplace_pld) return None elif isinstance(event, dp_event.MixtureOfGaussiansDpEvent): if self.neighboring_relation not in [ - NeighborRel.ADD_OR_REMOVE_ONE, NeighborRel.REPLACE_SPECIAL + NeighborRel.ADD_OR_REMOVE_ONE, + NeighborRel.REPLACE_SPECIAL, ]: return CompositionErrorDetails( invalid_event=event, @@ -194,6 +203,33 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, ).self_compose(count) self._pld = self._pld.compose(eps_dp_pld) return None + elif isinstance(event, dp_event.PermuteAndFlipDpEvent): + if self.neighboring_relation not in [ + NeighborRel.ADD_OR_REMOVE_ONE, + NeighborRel.REPLACE_SPECIAL, + ]: + return CompositionErrorDetails( + invalid_event=event, + error_message=( + 'neighboring_relation must be `ADD_OR_REMOVE_ONE` or ' + '`REPLACE_SPECIAL` for `PermuteAndFlipDpEvent`. Found ' + f'{self._neighboring_relation}.' + ), + ) + if do_compose: + if event.epsilon < 0: + raise ValueError(f'epsilon must be >= 0. Got {event.epsilon}') + if event.epsilon == 0: + pass # NoOp: zero epsilon means infinite noise. + else: + # Permute-and-flip has the same privacy loss distribution as the + # Laplace mechanism with parameter 1/epsilon. + pf_pld = PLD.from_laplace_mechanism( + parameter=1.0 / event.epsilon, + value_discretization_interval=self._value_discretization_interval, + ).self_compose(count) + self._pld = self._pld.compose(pf_pld) + return None elif isinstance(event, dp_event.PoissonSampledDpEvent): if isinstance(event.event, dp_event.GaussianDpEvent): if do_compose: @@ -204,8 +240,7 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, else: subsampled_gaussian_pld = PLD.from_gaussian_mechanism( standard_deviation=event.event.noise_multiplier, - value_discretization_interval=self - ._value_discretization_interval, + value_discretization_interval=self._value_discretization_interval, sampling_prob=event.sampling_probability, neighboring_relation=self.neighboring_relation, ).self_compose(count) @@ -213,7 +248,8 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, return None elif isinstance(event.event, dp_event.LaplaceDpEvent): if self.neighboring_relation not in [ - NeighborRel.ADD_OR_REMOVE_ONE, NeighborRel.REPLACE_SPECIAL + NeighborRel.ADD_OR_REMOVE_ONE, + NeighborRel.REPLACE_SPECIAL, ]: return CompositionErrorDetails( invalid_event=event, @@ -231,9 +267,9 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, else: subsampled_laplace_pld = PLD.from_laplace_mechanism( parameter=event.event.noise_multiplier, - value_discretization_interval=self - ._value_discretization_interval, - sampling_prob=event.sampling_probability).self_compose(count) + value_discretization_interval=self._value_discretization_interval, + sampling_prob=event.sampling_probability, + ).self_compose(count) self._pld = self._pld.compose(subsampled_laplace_pld) return None else: @@ -270,7 +306,8 @@ def _maybe_compose(self, event: dp_event.DpEvent, count: int, else: # Unsupported event (including `UnsupportedDpEvent`). return CompositionErrorDetails( - invalid_event=event, error_message='Unsupported event.') + invalid_event=event, error_message='Unsupported event.' + ) def get_epsilon(self, target_delta: float) -> float: if self._contains_non_dp_event: 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..439303a4 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) @@ -237,6 +289,32 @@ def test_exponential_mechanism_basic(self): self.assertEqual(accountant.get_delta(3.0), 0.0) self.assertEqual(accountant.get_epsilon(0.0), 3.0) + def test_permute_and_flip_basic(self): + event1 = dp_event.PermuteAndFlipDpEvent(1.0) + event2 = dp_event.PermuteAndFlipDpEvent(2.0) + accountant = pld_privacy_accountant.PLDAccountant() + accountant.compose(event1) + accountant.compose(event2) + # Must satisfy at least epsilon-DP with epsilon=3. + self.assertLessEqual(accountant.get_delta(3.0), 0.0 + 1e-10) + # Laplace PLD gives inf at delta=0 (unbounded support). At small delta, + # epsilon should be close to but slightly below the pure-DP bound of 3. + self.assertLess(accountant.get_epsilon(1e-10), 3.0 + 0.1) + + def test_permute_and_flip_tighter_than_exp_mech(self): + # For fixed epsilon and delta > 0, permute-and-flip (Laplace PLD) should + # give tighter epsilon than the exponential mechanism (black-box eps-DP). + eps = 1.0 + target_delta = 1e-5 + pf_accountant = pld_privacy_accountant.PLDAccountant() + pf_accountant.compose(dp_event.PermuteAndFlipDpEvent(eps)) + em_accountant = pld_privacy_accountant.PLDAccountant() + em_accountant.compose(dp_event.ExponentialMechanismDpEvent(eps)) + self.assertLessEqual( + pf_accountant.get_epsilon(target_delta), + em_accountant.get_epsilon(target_delta), + ) + def test_gaussian_basic(self): gaussian_event = dp_event.GaussianDpEvent(noise_multiplier=math.sqrt(3)) accountant = pld_privacy_accountant.PLDAccountant() @@ -248,9 +326,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 +342,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 +351,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 +384,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 +402,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 +421,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( 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..1661611e 100644 --- a/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant.py +++ b/python/dp_accounting/dp_accounting/rdp/rdp_privacy_accountant.py @@ -1059,6 +1059,17 @@ def _maybe_compose( xi = eps / np.expm1(eps) - 1 - np.log(eps / np.expm1(eps)) - eps**2 / 8 self._rdp += np.minimum(xi + (eps**2 / 8) * self._orders, eps) return None + elif isinstance(event, dp_event.PermuteAndFlipDpEvent): + if do_compose: + if event.epsilon < 0: + raise ValueError(f'epsilon must be >= 0. Got {event.epsilon}') + # Permute-and-flip satisfies standard epsilon-DP. Its privacy loss + # distribution is identical to the Laplace mechanism with parameter + # 1/epsilon, so we use the tight Laplace RDP formula. + self._rdp += count * np.array( + [_laplace_rdp(event.epsilon, order) for order in self._orders] + ) + return None elif isinstance(event, dp_event.PoissonSampledDpEvent): if self._neighboring_relation not in [ NeighborRel.ADD_OR_REMOVE_ONE, 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..a139eda1 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 @@ -487,6 +487,23 @@ def test_compute_rdp_exponential_mechanism(self): self.assertAlmostEqual(accountant._rdp[0], 0.123301561) self.assertAlmostEqual(accountant._rdp[1], 1.0) + def test_compute_rdp_permute_and_flip(self): + alphas = [1.0, 1000.0] + accountant = rdp_privacy_accountant.RdpAccountant(orders=alphas) + accountant.compose(dp_event.PermuteAndFlipDpEvent(1.0)) + # Should match Laplace RDP with eps=1. + # At order 1 (KL divergence): eps + exp(-eps) - 1 + + expected_kl = 1.0 + math.expm1(-1.0) # ~0.6321 + self.assertAlmostEqual(accountant._rdp[0], expected_kl) + # At very high order, RDP -> epsilon + self.assertAlmostEqual(accountant._rdp[1], 1.0, places=2) + + def test_supports_permute_and_flip(self): + aor_accountant = rdp_privacy_accountant.RdpAccountant() + event = dp_event.PermuteAndFlipDpEvent(1.0) + self.assertTrue(aor_accountant.supports(event)) + @parameterized.named_parameters( ('simple', _LAPLACE_EVENT), ('composed', dp_event.ComposedDpEvent([_LAPLACE_EVENT])),