From 2a7733d0416e11d0d5daedb7563ec7e35b665cff Mon Sep 17 00:00:00 2001 From: "Nelles, David" Date: Mon, 22 Jan 2024 15:01:39 +0100 Subject: [PATCH 1/3] feat: Added hash function for AffineScalarFunc class --- uncertainties/core.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/uncertainties/core.py b/uncertainties/core.py index a5870dfe..00cb48a0 100644 --- a/uncertainties/core.py +++ b/uncertainties/core.py @@ -1756,6 +1756,17 @@ def __bool__(self): ######################################## + def __hash__(self): + """ + Calculates the hash for any AffineScalarFunc object. + + Returns: + int: The hash of this object + """ + + ids = [id(d) for d in self.derivatives.keys()] + return hash((self._nominal_value, self._linear_part, tuple(ids))) + # Uncertainties handling: def error_components(self): From 86a25206ccedd76c4a4859f1d453572834b9c1e2 Mon Sep 17 00:00:00 2001 From: Michael Tiemann <72577720+MichaelTiemannOSC@users.noreply.github.com> Date: Wed, 5 Jul 2023 19:44:35 -0400 Subject: [PATCH 2/3] Implement hash invariant --- tests/test_uncertainties.py | 18 ++++++++++++++++++ uncertainties/core.py | 23 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_uncertainties.py b/tests/test_uncertainties.py index 46078747..0d735ce3 100644 --- a/tests/test_uncertainties.py +++ b/tests/test_uncertainties.py @@ -1935,3 +1935,21 @@ def test_correlated_values_correlation_mat(): assert uarrays_close( numpy.array(cov_mat), numpy.array(uncert_core.covariance_matrix([x2, y2, z2]))) + + def test_hash(): + ''' + Tests the invariance that if x==y, then hash(x)==hash(y) + ''' + + x = ufloat(1.23, 2.34) + y = ufloat(1.23, 2.34) + # nominal values and std_dev terms are equal, but... + assert x.n==y.n and x.s==y.s + # ...x and y are independent variables, therefore not equal as uncertain numbers + assert x != y + assert hash(x) != hash(y) + + # the equation (2x+x)/3 is equal to the variable x, so... + assert ((2*x+x)/3)==x + # ...hash of the equation and the variable should be equal + assert hash((2*x+x)/3)==hash(x) diff --git a/uncertainties/core.py b/uncertainties/core.py index 00cb48a0..52ef921c 100644 --- a/uncertainties/core.py +++ b/uncertainties/core.py @@ -1826,6 +1826,15 @@ def std_dev(self): # Abbreviation (for formulas, etc.): s = std_dev + def __hash__(self): + if not self._linear_part.expanded(): + self._linear_part.expand() + combo = tuple(iter(self._linear_part.linear_combo.items())) + if len(combo) > 1 or combo[0][1] != 1.0: + return hash(combo) + # The unique value that comes from a unique variable (which it also hashes to) + return id(combo[0][0]) + def __repr__(self): # Not putting spaces around "+/-" helps with arrays of # Variable, as each value with an uncertainty is a @@ -2792,7 +2801,19 @@ def __hash__(self): # variables, so they never compare equal; therefore, their # id() are allowed to differ # (http://docs.python.org/reference/datamodel.html#object.__hash__): - return id(self) + + # Also, since the _linear_part of a variable is based on self, we can use + # that as a hash (uniqueness of self), which allows us to also + # preserve the invariance that x == y implies hash(x) == hash(y) + if hasattr(self, '_linear_part'): + if ( + hasattr(self._linear_part, 'linear_combo') + and self in iter(self._linear_part.linear_combo.keys()) + ): + return id(tuple(iter(self._linear_part.linear_combo.keys()))[0]) + return hash(self._linear_part) + else: + return id(self) def __copy__(self): """ From 07eeca541e062dc995ebde35829b9b570a509991 Mon Sep 17 00:00:00 2001 From: "Nelles, David" Date: Thu, 25 Jan 2024 13:01:59 +0100 Subject: [PATCH 3/3] feat: Changed hash implementation in AffineScalarFunc and added further unit test to check edge cases. --- tests/test_uncertainties.py | 35 ++++++++++--- uncertainties/core.py | 99 +++++++++++++++++++++++++------------ 2 files changed, 94 insertions(+), 40 deletions(-) diff --git a/tests/test_uncertainties.py b/tests/test_uncertainties.py index 0d735ce3..b38721a6 100644 --- a/tests/test_uncertainties.py +++ b/tests/test_uncertainties.py @@ -5,7 +5,7 @@ from math import isnan import uncertainties.core as uncert_core -from uncertainties.core import ufloat, AffineScalarFunc, ufloat_fromstr +from uncertainties.core import ufloat, AffineScalarFunc, ufloat_fromstr, LinearCombination from uncertainties import umath from helpers import (power_special_cases, power_all_cases, power_wrt_ref,numbers_close, ufloats_close, compare_derivatives, uarrays_close) @@ -1941,15 +1941,34 @@ def test_hash(): Tests the invariance that if x==y, then hash(x)==hash(y) ''' - x = ufloat(1.23, 2.34) - y = ufloat(1.23, 2.34) + a = ufloat(1.23, 2.34) + b = ufloat(1.23, 2.34) + # nominal values and std_dev terms are equal, but... - assert x.n==y.n and x.s==y.s + assert a.n==b.n and a.s==b.s # ...x and y are independent variables, therefore not equal as uncertain numbers - assert x != y - assert hash(x) != hash(y) + assert a != b + assert hash(a) != hash(b) + + # order of calculation should be irrelevant + assert a + b == b + a + assert hash(a + b) == hash(b + a) # the equation (2x+x)/3 is equal to the variable x, so... - assert ((2*x+x)/3)==x + assert ((2*a+a)/3)==a # ...hash of the equation and the variable should be equal - assert hash((2*x+x)/3)==hash(x) + assert hash((2*a+a)/3)==hash(a) + + c = ufloat(1.23, 2.34) + + # the values of the linear combination entries matter + x = AffineScalarFunc(1, LinearCombination({a:1, b:2, c:1})) + y = AffineScalarFunc(1, LinearCombination({a:1, b:2, c:2})) + assert x != y + assert hash(x) != hash(y) + + # the order of linear combination values matter and should not lead to the same hash + x = AffineScalarFunc(1, LinearCombination({a:1, b:2})) + y = AffineScalarFunc(1, LinearCombination({a:2, b:1})) + assert x != y + assert hash(x) != hash(y) diff --git a/uncertainties/core.py b/uncertainties/core.py index 52ef921c..58b58558 100644 --- a/uncertainties/core.py +++ b/uncertainties/core.py @@ -1502,6 +1502,16 @@ def __bool__(self): """ return bool(self.linear_combo) + def copy(self): + """Shallow copy of the LinearCombination object. + + Returns: + LinearCombination: Copy of the object. + """ + cpy = LinearCombination.__new__(LinearCombination) + cpy.linear_combo = self.linear_combo.copy() + return cpy + def expanded(self): """ Return True if and only if the linear combination is expanded. @@ -1759,13 +1769,16 @@ def __bool__(self): def __hash__(self): """ Calculates the hash for any AffineScalarFunc object. + The hash is calculated from the nominal_value, and the derivatives. Returns: int: The hash of this object """ - ids = [id(d) for d in self.derivatives.keys()] - return hash((self._nominal_value, self._linear_part, tuple(ids))) + # derivatives which are zero must be filtered out, because the variable is insensitive to errors in those correlations. + # the derivatives must be sorted, because the hash depends on the order, but the equality of variables does not. + derivatives = sorted([(id(key), value) for key, value in self.derivatives.items() if value != 0]) + return hash((self._nominal_value, tuple(derivatives))) # Uncertainties handling: @@ -1826,15 +1839,6 @@ def std_dev(self): # Abbreviation (for formulas, etc.): s = std_dev - def __hash__(self): - if not self._linear_part.expanded(): - self._linear_part.expand() - combo = tuple(iter(self._linear_part.linear_combo.items())) - if len(combo) > 1 or combo[0][1] != 1.0: - return hash(combo) - # The unique value that comes from a unique variable (which it also hashes to) - return id(combo[0][0]) - def __repr__(self): # Not putting spaces around "+/-" helps with arrays of # Variable, as each value with an uncertainty is a @@ -2442,6 +2446,7 @@ def __setstate__(self, data_dict): """ Hook for the pickle module. """ + for (name, value) in data_dict.items(): # Contrary to the default __setstate__(), this does not # necessarily save to the instance dictionary (because the @@ -2749,7 +2754,7 @@ def __init__(self, value, std_dev, tag=None): # differentiable functions: for instance, Variable(3, 0.1)/2 # has a nominal value of 3/2 = 1, but a "shifted" value # of 3.1/2 = 1.55. - value = float(value) + self._nominal_value = float(value) # If the variable changes by dx, then the value of the affine # function that gives its value changes by 1*dx: @@ -2759,7 +2764,7 @@ def __init__(self, value, std_dev, tag=None): # takes much more memory. Thus, this implementation chooses # more cycles and a smaller memory footprint instead of no # cycles and a larger memory footprint. - super(Variable, self).__init__(value, LinearCombination({self: 1.})) + super(Variable, self).__init__(self._nominal_value, LinearCombination({self: 1.})) self.std_dev = std_dev # Assignment through a Python property @@ -2786,6 +2791,27 @@ def std_dev(self, std_dev): self._std_dev = float(std_dev) + def __hash__(self): + """ + Calculates the hash for any `Variable` object. + The implementation is the same as for `AffineScalarFunc`. + But this method sets the `_linear_part` manually. + It is set to a single entry with a self reference as key and 1.0 as value. + + Returns: + int: The hash of this object + """ + + # The manual implementation of the _linear_part is necessary, because pickle would not work otherwise. + # That is because of the self reference inside the _linear_part. + return hash((self._nominal_value, ((id(self), 1.),))) + + # Support for legacy method: + def set_std_dev(self, value): # Obsolete + deprecation('instead of set_std_dev(), please use' + ' .std_dev = ...') + self.std_dev = value + # The following method is overridden so that we can represent the tag: def __repr__(self): @@ -2796,25 +2822,6 @@ def __repr__(self): else: return "< %s = %s >" % (self.tag, num_repr) - def __hash__(self): - # All Variable objects are by definition independent - # variables, so they never compare equal; therefore, their - # id() are allowed to differ - # (http://docs.python.org/reference/datamodel.html#object.__hash__): - - # Also, since the _linear_part of a variable is based on self, we can use - # that as a hash (uniqueness of self), which allows us to also - # preserve the invariance that x == y implies hash(x) == hash(y) - if hasattr(self, '_linear_part'): - if ( - hasattr(self._linear_part, 'linear_combo') - and self in iter(self._linear_part.linear_combo.keys()) - ): - return id(tuple(iter(self._linear_part.linear_combo.keys()))[0]) - return hash(self._linear_part) - else: - return id(self) - def __copy__(self): """ Hook for the standard copy module. @@ -2849,6 +2856,34 @@ def __deepcopy__(self, memo): return self.__copy__() + def __getstate__(self): + """ + Hook for the pickle module. + + Same as for the AffineScalarFunction but remove the linear part, + since it only contains a self reference. + This would lead to problems when unpickling the linear part. + """ + + LINEAR_PART_NAME = "_linear_part" + state = super().__getstate__() + + if LINEAR_PART_NAME in state: + del state[LINEAR_PART_NAME] + + return state + + def __setstate__(self, state): + """ + Hook for the pickle module. + + Same as for AffineScalarFunction, but manually set the linear part. + This one is removed when pickling Variable objects. + """ + + super().__setstate__(state) + self._linear_part = LinearCombination({self: 1.}) + ###############################################################################