Skip to content

Refactor power derivative functions #294

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Changes
time. Now such a function can be imported but if the user attempts to
execute it, a `NotImplementedError` is raised indicating that the
function can't be used because `numpy` couldn't be imported.
- Refactored the implementation for the calculation of the derivatives of the power
function and improves the corresponding testing.

Adds:

Expand Down
58 changes: 58 additions & 0 deletions doc/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,64 @@ To check whether the uncertainty is NaN or Inf, use one of :func:`math.isnan`,
.. index:: correlations; detailed example


Power Function Behavior
=======================

The value of one :class:`UFloat` raised to the power of another can be calculated in two
ways:

>>> from uncertainties import umath
>>>
>>> x = ufloat(4.5, 0.2)
>>> y = ufloat(3.4, 0.4)
>>> print(x**y)
(1.7+/-1.0)e+02
>>> print(umath.pow(x, y))
(1.7+/-1.0)e+02

The function ``x**y`` is defined for all ``x != 0`` and for ``x == 0`` as long as
``y > 0``.
There is not a unique definition for ``0**0``, however python takes the convention for
:class:`float` that ``0**0 == 1``.
If the power operation is performed on an ``(x, y)`` pair for which ``x**y`` is
undefined then an exception will be raised:

>>> x = ufloat(0, 0.2)
>>> y = ufloat(-3.4, 0.4)
>>> print(x**y)
Traceback (most recent call last):
...
ZeroDivisionError: 0.0 cannot be raised to a negative power

On the domain where it is defined, ``x**y`` is always real for ``x >= 0``.
For ``x < 0`` it is real for all integer values of ``y``.
If ``x<0`` and ``y`` is not an integer then ``x**y`` has a non-zero imaginary component.
The :mod:`uncertainties` module does not handle complex values:

>>> x = ufloat(-4.5, 0.2)
>>> y = ufloat(-3.4, 0.4)
>>> print(x**y)
Traceback (most recent call last):
...
ValueError: The uncertainties module does not handle complex results

The ``x`` derivative is real anywhere ``x**y`` is real except along ``x==0`` for
non-integer ``y``.
At these points the ``x`` derivative would be complex so a NaN value is used:

>>> x = ufloat(0, 0.2)
>>> y=1.5
>>> print((x**y).error_components())
{0.0+/-0.2: nan}

The ``y`` derivative is real anywhere ``x**y`` is real as long as ``x>=0``.
For ``x < 0`` the ``y`` derivative is always complex valued so a NaN value is used:

>>> x = -2
>>> y = ufloat(1, 0.2)
>>> print((x**y).error_components())
{1.0+/-0.2: nan}

Automatic correlations
======================

Expand Down
29 changes: 29 additions & 0 deletions tests/test_power.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,40 @@
import pytest

from uncertainties import ufloat
from uncertainties.ops import pow_deriv_0, pow_deriv_1
from uncertainties.umath_core import pow as umath_pow

from helpers import nan_close


pow_deriv_cases = [
(0.5, 2, 1.0, -0.17328679513998632),
(0.5, 1.5, 1.0606601717798214, -0.2450645358671368),
(0.5, 0, 0.0, -0.6931471805599453),
(0.5, -1.5, -8.485281374238571, -1.9605162869370945),
(0.5, -2, -16.0, -2.772588722239781),
(0, 2, 0, 0),
(0, 1.5, float("nan"), 0),
(0, 0, 0, float("nan")),
(0, -0.5, float("nan"), float("nan")),
(0, -2, float("nan"), float("nan")),
(-0.5, 2, -1.0, float("nan")),
(-0.5, 1.5, float("nan"), float("nan")),
(-0.5, 0, -0.0, float("nan")),
(-0.5, -1.5, float("nan"), float("nan")),
(-0.5, -2, 16.0, float("nan")),
]


@pytest.mark.parametrize("x, y, x_deriv_expected, y_deriv_expected", pow_deriv_cases)
def test_pow_deriv_0(x, y, x_deriv_expected, y_deriv_expected):
x_deriv_actual = pow_deriv_0(x, y)
assert nan_close(x_deriv_actual, x_deriv_expected)

y_deriv_actual = pow_deriv_1(x, y)
assert nan_close(y_deriv_actual, y_deriv_expected)


zero = ufloat(0, 0.1)
zero2 = ufloat(0, 0.1)
one = ufloat(1, 0.1)
Expand Down
45 changes: 22 additions & 23 deletions uncertainties/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ def wrapped_f(*args, **kwargs):
return wrapped_f


def pow_deriv_0(x, y):
"""
The formula below works if x is positive or if y is an integer and x is negative
of y is an integer, x is zero and y is greater than or equal to 1.
"""
if x > 0 or (y % 1 == 0 and (x < 0 or y >= 1)):
return y * x ** (y - 1)
elif x == 0 and y == 0:
return 0
else:
return float("nan")


def pow_deriv_1(x, y):
if x > 0:
return log(x) * x**y
elif x == 0 and y > 0:
return 0
else:
return float("nan")


def get_ops_with_reflection():
"""
Return operators with a reflection, along with their partial derivatives.
Expand Down Expand Up @@ -119,29 +141,6 @@ def get_ops_with_reflection():
eval("lambda y, x: %s" % expr) for expr in reversed(derivatives)
]

# The derivatives of pow() are more complicated:

# The case x**y is constant one the line x = 0 and in y = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a shame to lose this comment. is there somewhere it could be moved to? I would say a docstring but that's not useful for __pow__.

maybe it could be added to umath.pow's docstring?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me take a look. I actually spent a VERY long time puzzling over various special cases and I had some rewrites of this same comment.

Part of the problem is that the old function handled different special cases using different approaches. For example, the old function allowed complex results to be returned but, instead, relied on some other part of the codebase to prevent complex results.

Under this PR the comment is not actually correct anymore but I can come up with replacement text. But I'm curious, do you want a comment for the developer's sake (like the old comment used to be) or for the user's sake? For the latter we can add some description under the umath.pow docstring. We can also add documentation to the docs webpage. If we want a comment for the developers sake then I can update the old comments with some notes about the various special cases and how they're handled.

Also happy to do all of the above, though it ends up being a lot of text because there's a few different cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's most useful for users so a docstring or section of the documentation, either works.

# the corresponding derivatives must be zero in these
# cases. If the function is actually not defined (e.g. 0**-3),
# then an exception will be raised when the nominal value is
# calculated. These derivatives are transformed to NaN if an
# error happens during their calculation:

def pow_deriv_0(x, y):
if y == 0:
return 0.0
elif x != 0 or y % 1 == 0:
return y * x ** (y - 1)
else:
return float("nan")

def pow_deriv_1(x, y):
if x == 0 and y > 0:
return 0.0
else:
return log(x) * x**y

ops_with_reflection["pow"] = [pow_deriv_0, pow_deriv_1]
ops_with_reflection["rpow"] = [
lambda y, x: pow_deriv_1(x, y),
Expand Down
Loading