Skip to content

Commit 3114177

Browse files
committed
unumpy.average: init
Ref: #38
1 parent 604df40 commit 3114177

File tree

4 files changed

+162
-0
lines changed

4 files changed

+162
-0
lines changed

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Fixes:
88

99
- fix `readthedocs` configuration so that the build passes (#254)
1010
- Add `unumpy.covariance_matrix`: A vectorized variant of the pure Python function `covariance_matrix` (#265)
11+
- Add `unumpy.average` to calculate uncertainties aware average (#264)
1112

1213
3.2.2 2024-July-08
1314
-----------------------

doc/numpy_guide.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,29 @@ functions is available in the documentation for :mod:`uncertainties.umath`.
144144
.. index::
145145
pair: testing and operations (in arrays); NaN
146146

147+
Uncertainties aware average
148+
---------------------------
149+
150+
If you have measured a certain value multiple times, with a different
151+
uncertainty every measurement. Averaging over the results in a manner aware of
152+
the different uncertainties, is not trivial. The function ``unumpy.average()``
153+
does that:
154+
155+
>>> measurements = numpy.array([2.1, 2.0, 2.05, 2.08, 2.02])
156+
>>> stds = numpy.array([0.05, 0.03, 0.04, 0.06, 0.05])
157+
>>> arr = unumpy.uarray(measurements, stds)
158+
>>> unumpy.average(arr)
159+
2.03606+/-0.019
160+
161+
Note how that function gives a value different from numpy's ``mean`` function:
162+
163+
>>> numpy.mean(arr)
164+
2.050+/-0.019
165+
166+
If you have an array with correlated values, the covariances will be considered
167+
as well. You can also specify an ``axes`` argument, to specify a certain axis
168+
or a tuple of axes upon which to average the result.
169+
147170
NaN testing and NaN-aware operations
148171
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
149172

tests/test_unumpy.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pytest
2+
13
try:
24
import numpy
35
except ImportError:
@@ -300,3 +302,70 @@ def test_array_comparisons():
300302
# For matrices, 1D arrays are converted to 2D arrays:
301303
mat = unumpy.umatrix([1, 2], [1, 4])
302304
assert numpy.all((mat == [mat[0, 0], 4]) == [True, False])
305+
306+
307+
class TestAverage:
308+
arr1d = unumpy.uarray(
309+
[2.1, 2.0, 2.05, 2.08, 2.02],
310+
[0.05, 0.03, 0.04, 0.06, 0.05],
311+
)
312+
313+
def __init__(self):
314+
sigma2d = 0.3
315+
means2d = np.linspace(4, 20, num=50).reshape(5, 10)
316+
self.arr2d = unumpy.uarray(
317+
np.random.normal(loc=means, scale=sigma2d),
318+
np.random.uniform(low=0, high=sigma2d),
319+
)
320+
meansNd = np.random.rand(4, 7, 5, 2, 10, 14) + 10
321+
self.arrNd = unumpy.uarray(
322+
meansNd, np.random.uniform(low=0, high=0.2, size=meansNd.shape)
323+
)
324+
325+
def test_average_type_check():
326+
with pytest.raises(ValueError):
327+
unumpy.average(numpy.array(["bla"]))
328+
329+
def test_average_example():
330+
"""Tests the example from the docs."""
331+
avg = unumpy.average(self.arr1d)
332+
assert np.isclose(avg.n, 2.0360612043435338)
333+
assert np.isclose(avg.s, 0.018851526708200846)
334+
335+
@pytest.mark.parametrize("invalid_axis", [1, 2, 3])
336+
def test_average1d_invalid_axes(invalid_axis):
337+
with pytest.raises(ValueError):
338+
unumpy.average(self.arr1d, axes=invalid_axis)
339+
340+
@pytest.mark.parametrize("invalid_axis", [2, 3])
341+
def test_average2d_invalid_axes(invalid_axis):
342+
with pytest.raises(ValueError):
343+
unumpy.average(self.arr1d, axes=invalid_axis)
344+
345+
@pytest.mark.parametrize(
346+
"expected_shape, axis_argument",
347+
[
348+
((), None),
349+
# According to the linspace reshape in __init__
350+
((5,), 1),
351+
((5,), (1,)),
352+
((10,), 0),
353+
((10,), (0,)),
354+
],
355+
)
356+
def test_average2d_shape(expected_shape, axis_argument):
357+
assert unumpy.average(self.arr2d, axes=axis_argument).shape == expected_shape
358+
359+
@pytest.mark.parametrize(
360+
"expected_shape, axis_argument",
361+
[
362+
((), None),
363+
# According to random.rand() argument in __init__
364+
((4, 5, 10), (1, 3, 5)),
365+
((10,), (0, 1, 2, 3, 4, 6)),
366+
((14,), (0, 1, 2, 3, 4, 5)),
367+
((7, 2), (0, 2, 4, 5, 6)),
368+
],
369+
)
370+
def test_averageNd_shape(expected_shape, axis_argument):
371+
assert unumpy.average(self.arrNd, axes=axis_argument).shape == expected_shape

uncertainties/unumpy/core.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,79 @@
3131
"nominal_values",
3232
"covariance_matrix",
3333
"std_devs",
34+
"average",
3435
# Classes:
3536
"matrix",
3637
]
3738

39+
40+
def _average(arr):
41+
"""
42+
The real implementation of average, for 1D arrays.
43+
"""
44+
assert arr.ndim == 1
45+
cov_matrix = covariance_matrix(arr)
46+
weights = numpy.diagonal(cov_matrix) ** -1
47+
weights_sum = weights.sum()
48+
return uarray(
49+
(nominal_values(arr) * weights).sum() / weights_sum,
50+
numpy.sqrt(
51+
numpy.einsum(
52+
"i,ij,j",
53+
weights,
54+
cov_matrix,
55+
weights,
56+
)
57+
) / weights_sum,
58+
)
59+
60+
def average(arr, axes=None):
61+
"""
62+
Return a weighted averaged along with a weighted mean over a certain axis
63+
or a axes. The formulas implemented by this are:
64+
65+
$$ \\mu = \frac{\\sum_i (x_i/\\sigma_i^2)}{\\sum_i \\sigma_i^{-2}}$$
66+
67+
$$\\sigma_\\mu = \\frac{\\sqrt{\\sum_{i,j} \\sigma_i^{-2} \\sigma_j^{-2} \\cdot Cov(x_i, x_j)}}{\\sum_i \\sigma_i^{-2}}$$
68+
69+
Where of course $Cov(x_i, x_i) = \\sigma_i^2$.
70+
71+
By default, (when ``axes=None``), it flattens the given array first and
72+
then applies the above equations. When axes is a list or tuple, it applies
73+
the same formula over each axis in a loop, and hence correlations between
74+
values in different axes are not taken into account.
75+
"""
76+
# NOTE regarding the above implementation disclaimer: Ideally we could have
77+
# taken the (2N)-D shaped covariance_matrix(arr) and worked with that, but
78+
# it is not very clear how to do that truely correctly.
79+
arr = np.asanyarray(arr)
80+
if axes is None:
81+
axes = [0]
82+
arr = arr.flatten()
83+
# The following sanity checks on axes similar to what Numpy 2.x's
84+
# lib.array_utils.normalize_axis_tuple do.
85+
elif not isinstance(axes, [tuple, list]):
86+
axes = [operator.index(axes)]
87+
else:
88+
axes = tuple(axes)
89+
if len(set(axes)) != len(axes):
90+
raise ValueError("repeated axis found in axes argument")
91+
# This one is not checked by np.lib.array_utils.normalize_axis_tuple
92+
if max(axes) >= arr.ndim:
93+
raise ValueError(
94+
f"Cannot average over an inexistent axis {max(axes)} >= arr.ndim = "
95+
f"{arr.ndim}"
96+
)
97+
if not isinstance(arr.flat[0], core.Variable):
98+
raise ValueError(
99+
"unumpy.average is meant to operate upon numpy arrays of ufloats, "
100+
"not pure numpy arrays"
101+
)
102+
for axis in sorted(axes, reverse=True):
103+
arr = numpy.apply_over_axis(_average, axis, arr)
104+
return arr
105+
106+
38107
###############################################################################
39108
# Utilities:
40109

0 commit comments

Comments
 (0)