Skip to content

Commit 9133c73

Browse files
committed
unumpy.average: init
Ref: #38
1 parent d41263a commit 9133c73

File tree

4 files changed

+165
-0
lines changed

4 files changed

+165
-0
lines changed

CHANGES.rst

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

99
- fix `readthedocs` configuration so that the build passes (#254)
10+
- Add ``unumpy.average`` to calculate uncertainties aware average (#38)
1011

1112
3.2.2 2024-July-08
1213
-----------------------

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: 67 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,68 @@ 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+
def __init__(self):
313+
sigma2d = 0.3
314+
means2d = np.linspace(4, 20, num=50).reshape(5, 10)
315+
self.arr2d = unumpy.uarray(
316+
np.random.normal(loc=means, scale=sigma2d),
317+
np.random.uniform(low=0, high=sigma2d)
318+
)
319+
meansNd = np.random.rand(4,7,5,2,10,14) + 10
320+
self.arrNd = unumpy.uarray(
321+
meansNd,
322+
np.random.uniform(low=0, high=0.2, size=meansNd.shape)
323+
)
324+
def test_average_type_check():
325+
with pytest.raises(ValueError):
326+
unumpy.average(numpy.array(["bla"]))
327+
def test_average_example():
328+
"""Tests the example from the docs."""
329+
avg = unumpy.average(self.arr1d)
330+
assert np.isclose(avg.n, 2.0360612043435338)
331+
assert np.isclose(avg.s, 0.018851526708200846)
332+
@pytest.mark.parametrize("invalid_axis", [1,2,3])
333+
def test_average1d_invalid_axes(invalid_axis):
334+
with pytest.raises(ValueError):
335+
unumpy.average(self.arr1d, axes=invalid_axis)
336+
@pytest.mark.parametrize("invalid_axis", [2,3])
337+
def test_average2d_invalid_axes(invalid_axis):
338+
with pytest.raises(ValueError):
339+
unumpy.average(self.arr1d, axes=invalid_axis)
340+
@pytest.mark.parametrize(
341+
"expected_shape, axis_argument", [
342+
((), None),
343+
# According to the linspace reshape in __init__
344+
((5,), 1),
345+
((5,), (1,)),
346+
((10,), 0),
347+
((10,), (0,)),
348+
]
349+
)
350+
def test_average2d_shape(expected_shape, axis_argument):
351+
assert unumpy.average(
352+
self.arr2d,
353+
axes=axis_argument
354+
).shape == expected_shape
355+
@pytest.mark.parametrize(
356+
"expected_shape, axis_argument", [
357+
((), None),
358+
# According to random.rand() argument in __init__
359+
((4,5,10), (1,3,5)),
360+
((10,), (0,1,2,3,4,6)),
361+
((14,), (0,1,2,3,4,5)),
362+
((7,2), (0,2,4,5,6)),
363+
]
364+
)
365+
def test_averageNd_shape(expected_shape, axis_argument):
366+
assert unumpy.average(
367+
self.arrNd,
368+
axes=axis_argument
369+
).shape == expected_shape

uncertainties/unumpy/core.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,84 @@
3030
# Utilities:
3131
"nominal_values",
3232
"std_devs",
33+
"average",
3334
# Classes:
3435
"matrix",
3536
]
3637

38+
def _flatten_array_by_axes(arr, axes):
39+
"""
40+
Return arr, reshaped such that the axes listed in parameter ``axes`` are
41+
flattened, and become the last axis of the resulting array. A utility used
42+
for `:func:uncertainties.unumpy.average`.
43+
"""
44+
arr = np.asanyarray(arr)
45+
if axes is None:
46+
return arr.flatten()
47+
# The following sanity checks on axes can be replaced by
48+
# np.lib.array_utils.normalize_axis_tuple once we will require a minimum
49+
# version of numpy 2.x.
50+
if type(axes) not in (tuple, list):
51+
try:
52+
axes = [operator.index(axes)]
53+
except TypeError:
54+
pass
55+
axes = tuple(axes)
56+
if len(set(axes)) != len(axes):
57+
raise ValueError('repeated axis found in axes argument')
58+
# This one is not checked by np.lib.array_utils.normalize_axis_tuple
59+
if max(axes) >= arr.ndim:
60+
raise ValueError(
61+
f'Cannot average over an inexistent axis {max(axes)} >= arr.ndim = '
62+
f'{arr.ndim}'
63+
)
64+
new_shape = []
65+
# To hold the product of the dimensions to flatten
66+
flatten_size = 1
67+
for i in range(len(arr.shape)):
68+
if i in axes:
69+
flatten_size *= arr.shape[i] # Multiply dimensions to flatten
70+
else:
71+
new_shape.append(arr.shape[i]) # Keep the dimension
72+
# This way the shapes to average over are flattend, in the end.
73+
new_shape.append(flatten_size)
74+
return arr.reshape(*new_shape)
75+
76+
def average(arr, axes=None):
77+
"""
78+
Return a weighted averaged along with a weighted mean over a certain axis
79+
or a axes. The formulas implemented by this are:
80+
81+
$$ \mu = \frac{\sum_i (x_i/\sigma_i^2)}{\sum_i \sigma_i^{-2}}$$
82+
83+
$$\sigma_\mu = \frac{\sqrt{\sum_{i,j} \sigma_i^{-2} \sigma_j^{-2} \cdot Cov(x_i, x_j)}}{\sum_i \sigma_i^{-2}}$$
84+
85+
Where of course $Cov(x_i, x_i) = \sigma_i^2$.
86+
87+
By default, operates on all axes of the given array.
88+
"""
89+
arr = _flatten_array_by_axes(arr, axes)
90+
if not isinstance(arr.flat[0], core.Variable):
91+
raise ValueError(
92+
"unumpy.average is meant to operate upon numpy arrays of ufloats, "
93+
"not pure numpy arrays"
94+
)
95+
cov_matrix = covariance_matrix(arr)
96+
weights = numpy.diagonal(cov_matrix, axis1=-2, axis2=-1)**-1
97+
weights_sum = weights.sum(axis=-1)
98+
return uarray(
99+
(nominal_values(arr)*weights).sum(axis=-1) / weights_sum,
100+
numpy.sqrt(
101+
numpy.einsum(
102+
'...i,...ij,...j',
103+
weights,
104+
cov_matrix,
105+
weights,
106+
)
107+
) / weights_sum
108+
)
109+
110+
37111
###############################################################################
38112
# Utilities:
39113

0 commit comments

Comments
 (0)