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 3, 2021
1 parent 7970f78 commit 5da802e
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 77 deletions.
62 changes: 29 additions & 33 deletions diffprivlib/mechanisms/snapping.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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))
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
88 changes: 45 additions & 43 deletions tests/mechanisms/test_Snapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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,101 @@ 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):
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 +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):
Expand All @@ -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):
Expand Down

0 comments on commit 5da802e

Please sign in to comment.