From 5da802e1e954ee16e3f74882e52803b843fe4c0a Mon Sep 17 00:00:00 2001 From: Dan Ristea Date: Wed, 1 Dec 2021 23:13:15 +0000 Subject: [PATCH] Add warning for floating-point precision --- diffprivlib/mechanisms/snapping.py | 62 ++++++++++----------- docs/modules/mechanisms.rst | 2 +- tests/mechanisms/test_Snapping.py | 88 +++++++++++++++--------------- 3 files changed, 75 insertions(+), 77 deletions(-) diff --git a/diffprivlib/mechanisms/snapping.py b/diffprivlib/mechanisms/snapping.py index 96d9bef..3a5ea39 100644 --- a/diffprivlib/mechanisms/snapping.py +++ b/diffprivlib/mechanisms/snapping.py @@ -1,9 +1,7 @@ - """ The Snapping mechanism in differential privacy, which eliminates a weakness to floating point errors in the classic Laplace mechanism with standard Laplace sampling. """ -import math import struct import crlibm @@ -41,26 +39,21 @@ class Snapping(LaplaceTruncated): .. [Mir12] Mironov, Ilya. "On significance of the least significant bits for differential privacy." Proceedings of the 2012 ACM conference on Computer and communications security (2012). """ + def __init__(self, *, epsilon, sensitivity, lower, upper): super().__init__(epsilon=epsilon, sensitivity=sensitivity, delta=0.0, lower=lower, upper=upper) - self.epsilon = self._check_epsilon_machine_epsilon(epsilon) self._bound = self._scale_bound() - self._epsilon_0 = self.effective_epsilon() - self.scale = 1.0 / self._epsilon_0 # everything is scaled to sensitivity 1 - self._lambda = self._get_nearest_power_of_2(self.scale) - @staticmethod - def _check_epsilon_machine_epsilon(epsilon): + @classmethod + def _check_epsilon_delta(cls, epsilon, delta): + epsilon, delta = super()._check_epsilon_delta(epsilon, delta) + machine_epsilon = np.finfo(float).epsneg - if epsilon < 2 * machine_epsilon: + if epsilon <= 2 * machine_epsilon: raise ValueError("Epsilon must be at least as large as twice the machine epsilon for the floating point " "type, as the effective epsilon must be non-negative") - return epsilon - def _check_all(self, value): - super()._check_all(value) - self._check_epsilon_machine_epsilon(self.epsilon) - return True + return epsilon, delta def _scale_bound(self): """ @@ -101,11 +94,8 @@ def effective_epsilon(self): float The effective value of :math:`\epsilon` """ - try: - return self._epsilon_0 - except AttributeError: - machine_epsilon = np.finfo(float).epsneg - return (self.epsilon - 2*machine_epsilon) / (1 + 12*self._bound*machine_epsilon) + machine_epsilon = np.finfo(float).epsneg + return (self.epsilon - 2 * machine_epsilon) / (1 + 12 * self._bound * machine_epsilon) def _scale_and_offset_value(self, value): """ @@ -137,11 +127,12 @@ def bits_to_float(b): return struct.unpack('>d', s)[0] bits = float_to_bits(x) - if bits % (1 << 52) == 0: + mantissa_size = np.finfo(float).nmant + if bits % (1 << mantissa_size) == 0: return x - return bits_to_float(((bits >> 52) + 1) << 52) + return bits_to_float(((bits >> mantissa_size) + 1) << mantissa_size) - def _round_to_nearest_power_of_2(self, value): + def _round_to_nearest_power_of_2(self, value, lambda_): """ Performs the rounding step from [Mir12]_ with ties resolved towards +∞ Parameters @@ -155,12 +146,12 @@ def _round_to_nearest_power_of_2(self, value): Rounded value """ - if self._epsilon_0 == float('inf'): # infinitely small rounding + if self.epsilon == float('inf'): # infinitely small rounding return value - remainder = value % self._lambda - if remainder > self._lambda / 2: - return value - remainder + self._lambda - if remainder == self._lambda / 2: + remainder = value % lambda_ + if remainder > lambda_ / 2: + return value - remainder + lambda_ + if remainder == lambda_ / 2: return value + remainder return value - remainder @@ -184,13 +175,14 @@ def _uniform_sampler(self): .. [Py21] The Python Standard Library. "random — Generate pseudo-random numbers", 2021 https://docs.python.org/3/library/random.html#recipes """ - mantissa = 1 << 52 | self._rng.getrandbits(52) - exponent = -53 + mantissa_size = np.finfo(float).nmant + mantissa = 1 << mantissa_size | self._rng.getrandbits(mantissa_size) + exponent = -(mantissa_size + 1) x = 0 while not x: x = self._rng.getrandbits(32) exponent += x.bit_length() - 32 - return math.ldexp(mantissa, exponent) + return np.ldexp(mantissa, exponent) @staticmethod def _laplace_sampler(unif_bit, unif): @@ -205,7 +197,7 @@ def _laplace_sampler(unif_bit, unif): float Random value from Laplace distribution scaled according to :math:`\epsilon` """ - laplace = (-1)**unif_bit * crlibm.log_rn(unif) + laplace = (-1) ** unif_bit * crlibm.log_rn(unif) return laplace def randomise(self, value): @@ -225,8 +217,12 @@ def randomise(self, value): self._check_all(value) if self.sensitivity == 0: return self._truncate(value) + value_scaled_offset = self._scale_and_offset_value(value) value_clamped = self._truncate(value_scaled_offset) - laplace = self.scale * self._laplace_sampler(self._rng.getrandbits(1), self._uniform_sampler()) - value_rounded = self._round_to_nearest_power_of_2(value_clamped + laplace) + + scale = 1.0 / self.effective_epsilon() # everything is scaled to sensitivity 1 + lambda_ = self._get_nearest_power_of_2(scale) + laplace = scale * self._laplace_sampler(self._rng.getrandbits(1), self._uniform_sampler()) + value_rounded = self._round_to_nearest_power_of_2(value_clamped + laplace, lambda_) return self._reverse_scale_and_offset_value(self._truncate(value_rounded)) diff --git a/docs/modules/mechanisms.rst b/docs/modules/mechanisms.rst index 39869ea..40e9587 100644 --- a/docs/modules/mechanisms.rst +++ b/docs/modules/mechanisms.rst @@ -118,7 +118,7 @@ Snapping mechanism .. autoclass:: Snapping :members: :inherited-members: - :exclude-members: copy,mse,variance + :exclude-members: copy,mse,bias,variance Staircase mechanism ----------------------------- diff --git a/tests/mechanisms/test_Snapping.py b/tests/mechanisms/test_Snapping.py index 071e732..66e5ff8 100644 --- a/tests/mechanisms/test_Snapping.py +++ b/tests/mechanisms/test_Snapping.py @@ -4,8 +4,6 @@ import numpy as np from unittest import TestCase -import pytest - from diffprivlib.mechanisms import Snapping from diffprivlib.utils import global_seed @@ -26,14 +24,14 @@ def test_class(self): def test_neg_sensitivity(self): with self.assertRaises(ValueError): - self.mech(epsilon=1, sensitivity=-1, lower=0, upper=1000) + self.mech(epsilon=1.0, sensitivity=-1, lower=0, upper=1000) def test_str_sensitivity(self): with self.assertRaises(TypeError): - self.mech(epsilon=1, sensitivity="1", lower=0, upper=1000) + self.mech(epsilon=1.0, sensitivity="1", lower=0, upper=1000) def test_zero_sensitivity(self): - mech = self.mech(epsilon=1, sensitivity=0, lower=0, upper=1000) + mech = self.mech(epsilon=1.0, sensitivity=0, lower=0, upper=1000) for i in range(1000): self.assertAlmostEqual(mech.randomise(1), 1) @@ -57,7 +55,7 @@ def test_string_epsilon(self): self.mech(epsilon="Two", sensitivity=1, lower=0, upper=1000) def test_epsilon(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) self.assertIsNotNone(mech.randomise(1)) def test_neg_effective_epsilon(self): @@ -65,89 +63,93 @@ def test_neg_effective_epsilon(self): self.mech(epsilon=np.nextafter((2*np.finfo(float).epsneg), 0), sensitivity=1, lower=0, upper=1000) def test_zero_effective_epsilon(self): - mech = self.mech(epsilon=2 * np.finfo(float).epsneg, sensitivity=1, lower=0, upper=1000) + with self.assertRaises(ValueError): + self.mech(epsilon=2 * np.finfo(float).epsneg, sensitivity=1, lower=0, upper=1000) + + def test_immediately_above_zero_effective_epsilon(self): + mech = self.mech(epsilon=np.nextafter(2 * np.finfo(float).epsneg, math.inf), sensitivity=1, lower=0, upper=1000) self.assertIsNotNone(mech.randomise(1)) def test_repr(self): - repr_ = repr(self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000)) + repr_ = repr(self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000)) self.assertIn(".Snapping(", repr_) def test_bias(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) with self.assertRaises(NotImplementedError): mech.bias(1) def test_variance(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) with self.assertRaises(NotImplementedError): mech.variance(1) def test_scale_bound_symmetric_sensitivity_1(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=-10, upper=10) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=-10, upper=10) self.assertAlmostEqual(mech._scale_bound(), 10) def test_scale_bound_nonsymmetric_sensitivity_1(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=-10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=-10, upper=30) self.assertAlmostEqual(mech._scale_bound(), 20) def test_scale_bound_nonsymmetric_sensitivity_0(self): - mech = self.mech(epsilon=1, sensitivity=0, lower=-10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=0, lower=-10, upper=30) self.assertAlmostEqual(mech._scale_bound(), 20) def test_scale_bound_nonsymmetric_sensitivity_subunitary(self): - mech = self.mech(epsilon=1, sensitivity=0.1, lower=-10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=0.1, lower=-10, upper=30) self.assertAlmostEqual(mech._scale_bound(), 200) def test_scale_bound_nonsymmetric_sensitivity_supraunitary(self): - mech = self.mech(epsilon=1, sensitivity=2, lower=-10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=2, lower=-10, upper=30) self.assertAlmostEqual(mech._scale_bound(), 10) def test_scale_and_offset_value_symmetric_sensitivity_1(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=-10, upper=10) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=-10, upper=10) self.assertAlmostEqual(mech._scale_and_offset_value(10), 10) def test_scale_and_offset_value_nonsymmetric_sensitivity_1(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=-10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=-10, upper=30) self.assertAlmostEqual(mech._scale_and_offset_value(10), 0) def test_scale_and_offset_value_nonsymmetric_sensitivity_1_lower_bound(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=-10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=-10, upper=30) self.assertAlmostEqual(mech._scale_and_offset_value(-10), -20) def test_scale_and_offset_value_nonsymmetric_sensitivity_1_upper_bound(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=10, upper=30) self.assertAlmostEqual(mech._scale_and_offset_value(30), 10) def test_scale_and_offset_value_symmetric_sensitivity_subunitary(self): - mech = self.mech(epsilon=1, sensitivity=0.1, lower=-10, upper=10) + mech = self.mech(epsilon=1.0, sensitivity=0.1, lower=-10, upper=10) self.assertAlmostEqual(mech._scale_and_offset_value(10), 100) def test_scale_and_offset_value_symmetric_sensitivity_supraunitary(self): - mech = self.mech(epsilon=1, sensitivity=2, lower=-10, upper=10) + mech = self.mech(epsilon=1.0, sensitivity=2, lower=-10, upper=10) self.assertAlmostEqual(mech._scale_and_offset_value(10), 5) def test_reverse_scale_and_offset_value_symmetric_sensitivity_1(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=-10, upper=10) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=-10, upper=10) self.assertAlmostEqual(mech._reverse_scale_and_offset_value(10), 10) def test_reverse_scale_and_offset_value_nonsymmetric_sensitivity_1(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=-10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=-10, upper=30) self.assertAlmostEqual(mech._reverse_scale_and_offset_value(0), 10) def test_reverse_scale_and_offset_value_nonsymmetric_sensitivity_1_lower_bound(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=-10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=-10, upper=30) self.assertAlmostEqual(mech._reverse_scale_and_offset_value(-20), -10) def test_reverse_scale_and_offset_value_nonsymmetric_sensitivity_1_upper_bound(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=10, upper=30) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=10, upper=30) self.assertAlmostEqual(mech._reverse_scale_and_offset_value(10), 30) def test_reverse_scale_and_offset_value_symmetric_sensitivity_subunitary(self): - mech = self.mech(epsilon=1, sensitivity=0.1, lower=-10, upper=10) + mech = self.mech(epsilon=1.0, sensitivity=0.1, lower=-10, upper=10) self.assertAlmostEqual(mech._reverse_scale_and_offset_value(100), 10) def test_reverse_scale_and_offset_value_symmetric_sensitivity_supraunitary(self): - mech = self.mech(epsilon=1, sensitivity=2, lower=-10, upper=10) + mech = self.mech(epsilon=1.0, sensitivity=2, lower=-10, upper=10) self.assertAlmostEqual(mech._reverse_scale_and_offset_value(5), 10) def test_get_nearest_power_of_2_exact(self): @@ -163,36 +165,36 @@ def test_get_nearest_power_of_2(self): sys.float_info.min) def test_round_to_nearest_power_of_2_exact(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) - self.assertAlmostEqual(mech._round_to_nearest_power_of_2(2.0), 2) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) + self.assertAlmostEqual(mech._round_to_nearest_power_of_2(2.0, 2.0), 2) def test_round_to_nearest_power_of_2_below_exact(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) - self.assertAlmostEqual(mech._round_to_nearest_power_of_2(np.nextafter(2.0, -math.inf)), 2) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) + self.assertAlmostEqual(mech._round_to_nearest_power_of_2(np.nextafter(2.0, -math.inf), 2.0), 2) def test_round_to_nearest_power_of_2_above_exact(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) - self.assertAlmostEqual(mech._round_to_nearest_power_of_2(np.nextafter(2.0, math.inf)), 2.0) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) + self.assertAlmostEqual(mech._round_to_nearest_power_of_2(np.nextafter(2.0, math.inf), 2.0), 2.0) def test_round_to_nearest_power_of_2_middle(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) - self.assertAlmostEqual(mech._round_to_nearest_power_of_2(3.0), 4) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) + self.assertAlmostEqual(mech._round_to_nearest_power_of_2(3.0, 2.0), 4) def test_round_to_nearest_power_of_2_below_middle(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) - self.assertAlmostEqual(mech._round_to_nearest_power_of_2(np.nextafter(3.0, -math.inf)), 2) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) + self.assertAlmostEqual(mech._round_to_nearest_power_of_2(np.nextafter(3.0, -math.inf), 2.0), 2) def test_round_to_nearest_power_of_2_above_middle(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) - self.assertAlmostEqual(mech._round_to_nearest_power_of_2(np.nextafter(3.0, math.inf)), 4.0) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) + self.assertAlmostEqual(mech._round_to_nearest_power_of_2(np.nextafter(3.0, math.inf), 2.0), 4.0) def test_non_numeric(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) with self.assertRaises(TypeError): mech.randomise("Hello") def test_zero_median_prob(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1000) vals = [] for i in range(10000): @@ -202,12 +204,12 @@ def test_zero_median_prob(self): self.assertAlmostEqual(np.abs(median), 0.0, delta=0.1) def test_effective_epsilon(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1) self.assertLess(mech.effective_epsilon(), 1.0) def test_within_bounds(self): - mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1) + mech = self.mech(epsilon=1.0, sensitivity=1, lower=0, upper=1) vals = [] for i in range(1000):