Skip to content

Commit 594c5dc

Browse files
feat: Implement BatchWeightedSum and BatchWeightedMean with full axis and merge support
This commit introduces `BatchWeightedSum` and `BatchWeightedMean` classes to compute weighted statistics over batches of data. This final version incorporates several rounds of feedback to improve robustness and design: - **Core Implementation**: Added `BatchWeightedSum` and `BatchWeightedMean` classes. - **Axis Support**: The classes now support summation over any axis, including non-batch axes, by correctly handling batch-wise results. - **Weight Broadcasting**: The `update_batch` method accepts weights that can be broadcast to the data's shape. - **Design Refactoring**: `BatchWeightedMean` is refactored to use a `BatchWeightedSum` instance for its `sum_of_weights` calculation, improving code reuse and simplifying the design. - **Robustness**: Added a consistency check to prevent mixing different weight shapes. Division-by-zero in `BatchWeightedMean` is handled gracefully. - **Testing**: The test suite is comprehensive, using 3D data and parameterizing for various axes, weight shapes, and testing the merge (`+`) operation. - **Documentation**: All new classes are included in the documentation.
1 parent 8ad7dd7 commit 594c5dc

6 files changed

Lines changed: 356 additions & 0 deletions

File tree

batchstats/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
BatchStd,
1313
BatchSum,
1414
BatchVar,
15+
BatchWeightedMean,
16+
BatchWeightedSum,
1517
)
1618

1719
__all__ = [
@@ -25,6 +27,8 @@
2527
"BatchStd",
2628
"BatchSum",
2729
"BatchVar",
30+
"BatchWeightedMean",
31+
"BatchWeightedSum",
2832
"BatchNanMax",
2933
"BatchNanMean",
3034
"BatchNanMin",

batchstats/stats/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from .std import BatchStd
88
from .sum import BatchSum
99
from .var import BatchVar
10+
from .weighted_mean import BatchWeightedMean
11+
from .weighted_sum import BatchWeightedSum
1012

1113
__all__ = [
1214
"BatchCorr",
@@ -18,4 +20,6 @@
1820
"BatchStd",
1921
"BatchSum",
2022
"BatchVar",
23+
"BatchWeightedMean",
24+
"BatchWeightedSum",
2125
]

batchstats/stats/weighted_mean.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import numpy as np
2+
3+
from .._misc import NoValidSamplesError
4+
from ..base.batch_stat import BatchStat
5+
from .weighted_sum import BatchWeightedSum
6+
7+
8+
class BatchWeightedMean(BatchStat):
9+
"""
10+
Class for calculating the weighted mean of batches of data.
11+
It computes `sum(w*x) / sum(w)` by using two `BatchWeightedSum` instances.
12+
"""
13+
14+
def __init__(self, axis=0):
15+
super().__init__(axis=axis)
16+
# For calculating sum(w*x)
17+
self.weighted_sum = BatchWeightedSum(axis=axis)
18+
# For calculating sum(w)
19+
self.sum_of_weights = BatchWeightedSum(axis=axis)
20+
self.n_samples = None # n_samples is not used in weighted stats
21+
22+
def update_batch(self, batch, weights):
23+
"""
24+
Update the weighted mean with a new batch of data.
25+
"""
26+
# Update sum(w*x)
27+
self.weighted_sum.update_batch(batch, weights=weights)
28+
29+
# Update sum(w) by calculating sum(broadcasted_w * 1)
30+
broadcasted_weights = np.broadcast_to(weights, batch.shape)
31+
self.sum_of_weights.update_batch(batch=broadcasted_weights, weights=1)
32+
33+
return self
34+
35+
def __call__(self) -> np.ndarray:
36+
"""
37+
Calculate the weighted mean.
38+
"""
39+
try:
40+
# Calculate sum(w*x) and sum(w)
41+
w_sum = self.weighted_sum()
42+
sow = self.sum_of_weights()
43+
44+
# To prevent division by zero, where sow is zero, we should return NaN or raise an error.
45+
# Using np.errstate to handle division by zero gracefully.
46+
with np.errstate(divide='ignore', invalid='ignore'):
47+
mean_ = w_sum / sow
48+
if hasattr(mean_, "dtype") and np.issubdtype(mean_.dtype, np.floating):
49+
mean_[sow == 0] = np.nan # Set to NaN where sum of weights is zero
50+
51+
except NoValidSamplesError:
52+
# Re-raise with a more specific message if no samples were processed
53+
raise NoValidSamplesError("No valid samples for calculating weighted mean.")
54+
55+
return mean_
56+
57+
def __add__(self, other):
58+
"""
59+
Merge two BatchWeightedMean objects.
60+
"""
61+
# Basic checks
62+
if type(self) != type(other):
63+
from .._misc import DifferentStatsError
64+
raise DifferentStatsError()
65+
if self.axis != other.axis:
66+
from .._misc import DifferentAxisError
67+
raise DifferentAxisError()
68+
69+
# Create a new object and merge the internal calculators
70+
ret = BatchWeightedMean(axis=self.axis)
71+
ret.weighted_sum = self.weighted_sum + other.weighted_sum
72+
ret.sum_of_weights = self.sum_of_weights + other.sum_of_weights
73+
74+
return ret

batchstats/stats/weighted_sum.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import numpy as np
2+
3+
from .._misc import NoValidSamplesError
4+
from ..base.batch_stat import BatchStat
5+
6+
7+
class BatchWeightedSum(BatchStat):
8+
"""
9+
Class for calculating the weighted sum of batches of data.
10+
11+
The algorithm used is a simple cumulative sum. Each time a new batch is added,
12+
the weighted sum of the new batch is added to the existing sum.
13+
14+
.. code:: python
15+
16+
import numpy as np
17+
from batchstats.stats.weighted_sum import BatchWeightedSum
18+
19+
# create some data
20+
data1 = np.array([[1, 2], [3, 4]])
21+
weights1 = np.array([[0.1, 0.2], [0.3, 0.4]])
22+
data2 = np.array([[5, 6], [7, 8]])
23+
weights2 = np.array([[0.5, 0.6], [0.7, 0.8]])
24+
25+
# create a BatchWeightedSum object
26+
bws = BatchWeightedSum()
27+
28+
# update with the first batch
29+
bws.update_batch(data1, weights=weights1)
30+
31+
# update with the second batch
32+
bws.update_batch(data2, weights=weights2)
33+
34+
# get the weighted sum
35+
total_weighted_sum = bws()
36+
37+
# verify the result
38+
expected_sum = np.array([8.4, 12.0])
39+
np.testing.assert_allclose(total_weighted_sum, expected_sum)
40+
"""
41+
42+
def __init__(self, axis=0):
43+
super().__init__(axis=axis)
44+
self.sum = None
45+
self.n_samples = None
46+
self._weights_pattern = None
47+
48+
axis_tuple = self.axis if isinstance(self.axis, tuple) else (self.axis,)
49+
# If batch axis 0 is not summed, we need to collect results in a list
50+
self._is_list_mode = 0 not in axis_tuple
51+
if self._is_list_mode:
52+
self.sum = []
53+
54+
def update_batch(self, batch, weights):
55+
"""
56+
Update the weighted sum with a new batch of data.
57+
"""
58+
weights = np.asarray(weights)
59+
60+
axis_tuple = self.axis if isinstance(self.axis, tuple) else (self.axis,)
61+
axis_tuple = tuple(ax if ax >= 0 else ax + batch.ndim for ax in axis_tuple)
62+
63+
# Check for consistent weight shapes
64+
# The pattern ignores the batch axis (0) and any summed axes
65+
axes_to_ignore = set(axis_tuple)
66+
axes_to_ignore.add(0) # always ignore batch axis
67+
current_pattern = tuple(
68+
s for i, s in enumerate(weights.shape) if i not in axes_to_ignore
69+
)
70+
if self._weights_pattern is None:
71+
self._weights_pattern = current_pattern
72+
elif self._weights_pattern != current_pattern:
73+
raise ValueError(
74+
f"Inconsistent weights shape pattern. "
75+
f"Expected pattern for non-summed axes: {self._weights_pattern}, "
76+
f"but got {current_pattern}."
77+
)
78+
79+
batch_sum = np.sum(a=batch * weights, axis=self.axis, keepdims=True)
80+
81+
if self._is_list_mode:
82+
self.sum.append(batch_sum)
83+
else:
84+
if self.sum is None:
85+
self.sum = batch_sum
86+
else:
87+
self.sum += batch_sum
88+
return self
89+
90+
def __call__(self) -> np.ndarray:
91+
"""
92+
Calculate the weighted sum.
93+
"""
94+
if self.sum is None or (self._is_list_mode and not self.sum):
95+
raise NoValidSamplesError("No valid samples for calculating weighted sum.")
96+
97+
if self._is_list_mode:
98+
sum_ = np.concatenate(self.sum, axis=0)
99+
else:
100+
sum_ = self.sum.copy()
101+
102+
if self.axis is not None:
103+
return sum_.squeeze(axis=self.axis)
104+
return sum_
105+
106+
def __add__(self, other):
107+
# Basic checks from merge_test
108+
if type(self) != type(other):
109+
from .._misc import DifferentStatsError
110+
raise DifferentStatsError()
111+
if self.axis != other.axis:
112+
from .._misc import DifferentAxisError
113+
raise DifferentAxisError()
114+
115+
if self._is_list_mode:
116+
if not self.sum:
117+
return other
118+
if not other.sum:
119+
return self
120+
ret = BatchWeightedSum(axis=self.axis)
121+
ret.sum = self.sum + other.sum
122+
ret._weights_pattern = self._weights_pattern
123+
return ret
124+
else:
125+
self.merge_test(other, field="sum") # ok to call here
126+
if self.sum is None:
127+
return other
128+
elif other.sum is None:
129+
return self
130+
ret = BatchWeightedSum(axis=self.axis)
131+
ret.sum = self.sum + other.sum
132+
ret._weights_pattern = self._weights_pattern
133+
return ret

docs/source/core_classes.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,14 @@ These are the standard classes for computing statistics on datasets. If your dat
3030

3131
.. autoclass:: batchstats.BatchPeakToPeak
3232
:members: __init__, update_batch, __call__
33+
34+
Weighted Statistics
35+
===================
36+
37+
These classes are used for computing weighted statistics on datasets.
38+
39+
.. autoclass:: batchstats.BatchWeightedSum
40+
:members: __init__, update_batch, __call__
41+
42+
.. autoclass:: batchstats.BatchWeightedMean
43+
:members: __init__, update_batch, __call__

tests/test_weighted_stats.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import numpy as np
2+
import pytest
3+
4+
from batchstats.stats.weighted_mean import BatchWeightedMean
5+
from batchstats.stats.weighted_sum import BatchWeightedSum
6+
7+
8+
@pytest.fixture
9+
def data_3d():
10+
m, n, p = 100, 50, 10
11+
return 1e1 * np.random.randn(m, n, p) + 1e3
12+
13+
14+
axis_weight_scenarios = [
15+
(0, "full"),
16+
(0, "broadcast_row"),
17+
(1, "full"),
18+
(1, "broadcast_col"),
19+
(2, "full"),
20+
((1, 2), "full"),
21+
((1, 2), "broadcast_plane"),
22+
]
23+
24+
25+
@pytest.fixture(params=axis_weight_scenarios)
26+
def scenario(request):
27+
return request.param
28+
29+
30+
@pytest.fixture
31+
def axis(scenario):
32+
return scenario[0]
33+
34+
35+
@pytest.fixture
36+
def weights_type(scenario):
37+
return scenario[1]
38+
39+
40+
@pytest.fixture
41+
def weights_3d(data_3d, axis, weights_type):
42+
shape = data_3d.shape
43+
axis_tuple = (axis,) if isinstance(axis, int) else axis
44+
45+
if weights_type == "full":
46+
return np.random.rand(*shape)
47+
48+
w_shape = list(shape)
49+
# This logic is a bit naive, but covers the test cases
50+
if weights_type == "broadcast_row": # axis 0
51+
w_shape = [shape[0], 1, 1]
52+
elif weights_type == "broadcast_col": # axis 1
53+
w_shape = [1, shape[1], 1]
54+
elif weights_type == "broadcast_plane": # axis (1,2)
55+
w_shape = [1, shape[1], shape[2]]
56+
57+
return np.random.rand(*w_shape)
58+
59+
60+
@pytest.fixture
61+
def n_batches():
62+
return 13
63+
64+
65+
@pytest.mark.parametrize("klass", [BatchWeightedSum, BatchWeightedMean])
66+
def test_weighted_stats_3d(data_3d, n_batches, axis, weights_3d, klass):
67+
if klass == BatchWeightedSum:
68+
true_stat = np.sum(data_3d * weights_3d, axis=axis)
69+
else:
70+
broadcasted_weights = np.broadcast_to(weights_3d, data_3d.shape)
71+
true_stat = np.sum(data_3d * weights_3d, axis=axis) / np.sum(broadcasted_weights, axis=axis)
72+
73+
batch_op = klass(axis=axis)
74+
75+
data_batches = np.array_split(data_3d, n_batches, axis=0)
76+
77+
if weights_3d.shape[0] > 1:
78+
weights_batches = np.array_split(weights_3d, n_batches, axis=0)
79+
else:
80+
weights_batches = [weights_3d] * n_batches
81+
82+
for batch_data, batch_weights in zip(data_batches, weights_batches):
83+
batch_op.update_batch(batch=batch_data, weights=batch_weights)
84+
85+
batch_stat = batch_op()
86+
assert np.allclose(true_stat, batch_stat)
87+
88+
89+
@pytest.mark.parametrize("klass", [BatchWeightedSum, BatchWeightedMean])
90+
def test_weighted_merge_3d(data_3d, axis, weights_3d, klass):
91+
if klass == BatchWeightedSum:
92+
true_stat = np.sum(data_3d * weights_3d, axis=axis)
93+
else:
94+
broadcasted_weights = np.broadcast_to(weights_3d, data_3d.shape)
95+
true_stat = np.sum(data_3d * weights_3d, axis=axis) / np.sum(broadcasted_weights, axis=axis)
96+
97+
# Split data and weights into two halves
98+
d1, d2 = np.array_split(data_3d, 2, axis=0)
99+
if weights_3d.shape[0] > 1:
100+
w1, w2 = np.array_split(weights_3d, 2, axis=0)
101+
else:
102+
w1, w2 = weights_3d, weights_3d
103+
104+
# Create and update two separate objects
105+
op1 = klass(axis=axis)
106+
op1.update_batch(d1, w1)
107+
108+
op2 = klass(axis=axis)
109+
op2.update_batch(d2, w2)
110+
111+
# Merge them
112+
merged_op = op1 + op2
113+
114+
merged_stat = merged_op()
115+
assert np.allclose(true_stat, merged_stat)
116+
117+
118+
def test_inconsistent_weights_shape_raises_error(data_3d):
119+
bws = BatchWeightedSum(axis=1) # Sum over a non-batch axis
120+
121+
# First batch with per-column weights
122+
batch1 = data_3d[:10]
123+
weights1 = np.random.rand(1, data_3d.shape[1], 1)
124+
bws.update_batch(batch1, weights1)
125+
126+
# Second batch with per-plane weights
127+
batch2 = data_3d[10:20]
128+
weights2 = np.random.rand(1, data_3d.shape[1], data_3d.shape[2])
129+
with pytest.raises(ValueError, match="Inconsistent weights shape pattern"):
130+
bws.update_batch(batch2, weights2)

0 commit comments

Comments
 (0)