Skip to content

Commit 9b1844d

Browse files
jagerber48newville
andauthored
Refactor power derivative functions (#294)
- [x] Closes #293 - [x] Executed `pre-commit run --all-files` with no errors - [x] The change is fully covered by automated unit tests - [x] Documented in docs/ as appropriate - [x] Added an entry to the CHANGES file `uncertainties` allows calculating `x**y` where one or both of `x` and `y` are `UFloat` objects. For this we need to calculate the `x` and `y` derivatives of `x**y`. This PR refactors the functions that calculate those derivatives to more explicitly and clearly handle non-typical cases. Moving those functions to the module level also allows these functions to be directly tested. --------- Co-authored-by: Matt Newville <newville@cars.uchicago.edu>
1 parent 3bd3b9d commit 9b1844d

File tree

4 files changed

+111
-23
lines changed

4 files changed

+111
-23
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Changes
1212
time. Now such a function can be imported but if the user attempts to
1313
execute it, a `NotImplementedError` is raised indicating that the
1414
function can't be used because `numpy` couldn't be imported.
15+
- Refactored the implementation for the calculation of the derivatives of the power
16+
function and improves the corresponding testing.
1517

1618
Adds:
1719

doc/user_guide.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,64 @@ To check whether the uncertainty is NaN or Inf, use one of :func:`math.isnan`,
271271
.. index:: correlations; detailed example
272272

273273

274+
Power Function Behavior
275+
=======================
276+
277+
The value of one :class:`UFloat` raised to the power of another can be calculated in two
278+
ways:
279+
280+
>>> from uncertainties import umath
281+
>>>
282+
>>> x = ufloat(4.5, 0.2)
283+
>>> y = ufloat(3.4, 0.4)
284+
>>> print(x**y)
285+
(1.7+/-1.0)e+02
286+
>>> print(umath.pow(x, y))
287+
(1.7+/-1.0)e+02
288+
289+
The function ``x**y`` is defined for all ``x != 0`` and for ``x == 0`` as long as
290+
``y > 0``.
291+
There is not a unique definition for ``0**0``, however python takes the convention for
292+
:class:`float` that ``0**0 == 1``.
293+
If the power operation is performed on an ``(x, y)`` pair for which ``x**y`` is
294+
undefined then an exception will be raised:
295+
296+
>>> x = ufloat(0, 0.2)
297+
>>> y = ufloat(-3.4, 0.4)
298+
>>> print(x**y)
299+
Traceback (most recent call last):
300+
...
301+
ZeroDivisionError: 0.0 cannot be raised to a negative power
302+
303+
On the domain where it is defined, ``x**y`` is always real for ``x >= 0``.
304+
For ``x < 0`` it is real for all integer values of ``y``.
305+
If ``x<0`` and ``y`` is not an integer then ``x**y`` has a non-zero imaginary component.
306+
The :mod:`uncertainties` module does not handle complex values:
307+
308+
>>> x = ufloat(-4.5, 0.2)
309+
>>> y = ufloat(-3.4, 0.4)
310+
>>> print(x**y)
311+
Traceback (most recent call last):
312+
...
313+
ValueError: The uncertainties module does not handle complex results
314+
315+
The ``x`` derivative is real anywhere ``x**y`` is real except along ``x==0`` for
316+
non-integer ``y``.
317+
At these points the ``x`` derivative would be complex so a NaN value is used:
318+
319+
>>> x = ufloat(0, 0.2)
320+
>>> y=1.5
321+
>>> print((x**y).error_components())
322+
{0.0+/-0.2: nan}
323+
324+
The ``y`` derivative is real anywhere ``x**y`` is real as long as ``x>=0``.
325+
For ``x < 0`` the ``y`` derivative is always complex valued so a NaN value is used:
326+
327+
>>> x = -2
328+
>>> y = ufloat(1, 0.2)
329+
>>> print((x**y).error_components())
330+
{1.0+/-0.2: nan}
331+
274332
Automatic correlations
275333
======================
276334

tests/test_power.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,40 @@
33
import pytest
44

55
from uncertainties import ufloat
6+
from uncertainties.ops import pow_deriv_0, pow_deriv_1
67
from uncertainties.umath_core import pow as umath_pow
78

89
from helpers import nan_close
910

1011

12+
pow_deriv_cases = [
13+
(0.5, 2, 1.0, -0.17328679513998632),
14+
(0.5, 1.5, 1.0606601717798214, -0.2450645358671368),
15+
(0.5, 0, 0.0, -0.6931471805599453),
16+
(0.5, -1.5, -8.485281374238571, -1.9605162869370945),
17+
(0.5, -2, -16.0, -2.772588722239781),
18+
(0, 2, 0, 0),
19+
(0, 1.5, float("nan"), 0),
20+
(0, 0, 0, float("nan")),
21+
(0, -0.5, float("nan"), float("nan")),
22+
(0, -2, float("nan"), float("nan")),
23+
(-0.5, 2, -1.0, float("nan")),
24+
(-0.5, 1.5, float("nan"), float("nan")),
25+
(-0.5, 0, -0.0, float("nan")),
26+
(-0.5, -1.5, float("nan"), float("nan")),
27+
(-0.5, -2, 16.0, float("nan")),
28+
]
29+
30+
31+
@pytest.mark.parametrize("x, y, x_deriv_expected, y_deriv_expected", pow_deriv_cases)
32+
def test_pow_deriv_0(x, y, x_deriv_expected, y_deriv_expected):
33+
x_deriv_actual = pow_deriv_0(x, y)
34+
assert nan_close(x_deriv_actual, x_deriv_expected)
35+
36+
y_deriv_actual = pow_deriv_1(x, y)
37+
assert nan_close(y_deriv_actual, y_deriv_expected)
38+
39+
1140
zero = ufloat(0, 0.1)
1241
zero2 = ufloat(0, 0.1)
1342
one = ufloat(1, 0.1)

uncertainties/ops.py

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ def wrapped_f(*args, **kwargs):
6464
return wrapped_f
6565

6666

67+
def pow_deriv_0(x, y):
68+
"""
69+
The formula below works if x is positive or if y is an integer and x is negative
70+
of y is an integer, x is zero and y is greater than or equal to 1.
71+
"""
72+
if x > 0 or (y % 1 == 0 and (x < 0 or y >= 1)):
73+
return y * x ** (y - 1)
74+
elif x == 0 and y == 0:
75+
return 0
76+
else:
77+
return float("nan")
78+
79+
80+
def pow_deriv_1(x, y):
81+
if x > 0:
82+
return log(x) * x**y
83+
elif x == 0 and y > 0:
84+
return 0
85+
else:
86+
return float("nan")
87+
88+
6789
def get_ops_with_reflection():
6890
"""
6991
Return operators with a reflection, along with their partial derivatives.
@@ -119,29 +141,6 @@ def get_ops_with_reflection():
119141
eval("lambda y, x: %s" % expr) for expr in reversed(derivatives)
120142
]
121143

122-
# The derivatives of pow() are more complicated:
123-
124-
# The case x**y is constant one the line x = 0 and in y = 0;
125-
# the corresponding derivatives must be zero in these
126-
# cases. If the function is actually not defined (e.g. 0**-3),
127-
# then an exception will be raised when the nominal value is
128-
# calculated. These derivatives are transformed to NaN if an
129-
# error happens during their calculation:
130-
131-
def pow_deriv_0(x, y):
132-
if y == 0:
133-
return 0.0
134-
elif x != 0 or y % 1 == 0:
135-
return y * x ** (y - 1)
136-
else:
137-
return float("nan")
138-
139-
def pow_deriv_1(x, y):
140-
if x == 0 and y > 0:
141-
return 0.0
142-
else:
143-
return log(x) * x**y
144-
145144
ops_with_reflection["pow"] = [pow_deriv_0, pow_deriv_1]
146145
ops_with_reflection["rpow"] = [
147146
lambda y, x: pow_deriv_1(x, y),

0 commit comments

Comments
 (0)