Skip to content

Commit

Permalink
Add warning for floating-point precision
Browse files Browse the repository at this point in the history
  • Loading branch information
danrr committed Dec 1, 2021
1 parent 7970f78 commit 0cc7c62
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 71 deletions.
50 changes: 24 additions & 26 deletions diffprivlib/mechanisms/snapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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))
2 changes: 1 addition & 1 deletion docs/modules/mechanisms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Snapping mechanism
.. autoclass:: Snapping
:members:
:inherited-members:
:exclude-members: copy,mse,variance
:exclude-members: copy,mse,bias,variance

Staircase mechanism
-----------------------------
Expand Down
105 changes: 61 additions & 44 deletions tests/mechanisms/test_Snapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down

0 comments on commit 0cc7c62

Please sign in to comment.