diff --git a/diffprivlib/mechanisms/snapping.py b/diffprivlib/mechanisms/snapping.py index 96d9bef..7d5e72c 100644 --- a/diffprivlib/mechanisms/snapping.py +++ b/diffprivlib/mechanisms/snapping.py @@ -5,11 +5,13 @@ """ import math import struct +import warnings import crlibm import numpy as np from diffprivlib.mechanisms import LaplaceTruncated +from diffprivlib.utils import DiffprivlibCompatibilityWarning class Snapping(LaplaceTruncated): @@ -43,24 +45,19 @@ class Snapping(LaplaceTruncated): """ 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): - machine_epsilon = np.finfo(float).epsneg - if epsilon < 2 * machine_epsilon: + @classmethod + def _check_epsilon_delta(cls, epsilon, delta): + if not (isinstance(epsilon, float) or isinstance(epsilon, np.float64)): + warnings.warn("The snapping mechanism expects epsilon to be a double precision floating-point number for" + "precise rounding; epsilon will be cast to 64-bit float", DiffprivlibCompatibilityWarning) + machine_epsilon = np.finfo(np.float64).epsneg + 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 super()._check_epsilon_delta(np.float64(epsilon), delta) def _scale_bound(self): """ @@ -101,11 +98,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(np.float64).epsneg + return (self.epsilon - 2*machine_epsilon) / (1 + 12*self._bound*machine_epsilon) def _scale_and_offset_value(self, value): """ @@ -141,7 +135,7 @@ def bits_to_float(b): return x return bits_to_float(((bits >> 52) + 1) << 52) - 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 +149,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 @@ -225,8 +219,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..5ec4f0d 100644 --- a/tests/mechanisms/test_Snapping.py +++ b/tests/mechanisms/test_Snapping.py @@ -4,10 +4,8 @@ import numpy as np from unittest import TestCase -import pytest - from diffprivlib.mechanisms import Snapping -from diffprivlib.utils import global_seed +from diffprivlib.utils import global_seed, DiffprivlibCompatibilityWarning class TestSnapping(TestCase): @@ -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,97 +55,116 @@ 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_int_epsilon(self): + with self.assertWarns(DiffprivlibCompatibilityWarning): + mech = self.mech(epsilon=1, sensitivity=1, lower=0, upper=1000) + self.assertEqual(mech.effective_epsilon(), 0.9999999999993336) + + def test_float16_epsilon(self): + with self.assertWarns(DiffprivlibCompatibilityWarning): + mech = self.mech(epsilon=np.float16(1.0), sensitivity=1, lower=0, upper=1000) + self.assertEqual(mech.effective_epsilon(), 0.9999999999993336) + + def test_float32_epsilon(self): + with self.assertWarns(DiffprivlibCompatibilityWarning): + mech = self.mech(epsilon=np.float32(1.0), sensitivity=1, lower=0, upper=1000) + self.assertEqual(mech.effective_epsilon(), 0.9999999999993336) + def test_neg_effective_epsilon(self): with self.assertRaises(ValueError): 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 +180,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 +219,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):