Skip to content

Commit ae9f56e

Browse files
author
OCCAM Coder
committed
Use the input length for the sum dtype guesses and keep pure python integer sums exact
1 parent 026e3a9 commit ae9f56e

4 files changed

Lines changed: 49 additions & 4 deletions

File tree

‎numpy_groupies/aggregate_numpy.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,10 @@ def _aggregate_base(
669669
a = a[good]
670670
group_idx = group_idx[good]
671671

672-
dtype = check_dtype(dtype, funcname, a, flat_size)
672+
# the sum overflow guess needs the maximum number of values that can
673+
# be summed into a single group, i.e. the (nan-filtered) input length
674+
# - flat_size counts output groups and can be arbitrarily smaller
675+
dtype = check_dtype(dtype, funcname, a, len(group_idx))
673676
fill_value = resolve_fill_value(funcname, fill_value, dtype)
674677
check_fill_value(fill_value, dtype, func=funcname)
675678
ret = func(group_idx, a, flat_size, fill_value=fill_value, dtype=dtype, **kwargs) # type: ignore[operator]

‎numpy_groupies/aggregate_pandas.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ def _wrapper(
6767
else:
6868
result = getattr(grouped, funcname)(**kwargs)
6969

70-
dtype = check_dtype(dtype, funcname, a, size)
70+
# the sum overflow guess needs the maximum number of values that can be
71+
# summed into a single group, i.e. the input length - not the output size
72+
dtype = check_dtype(dtype, funcname, a, len(group_idx))
7173
if funcname.startswith("cum"):
7274
ret = result.to_numpy()
7375
else:

‎numpy_groupies/aggregate_purepy.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,20 @@
2020
if TYPE_CHECKING:
2121
import numpy.typing as npt
2222

23+
2324
# min, max, sum, all, any - builtin
2425

2526

27+
def _sum(x: Sequence[Any]) -> Any:
28+
# builtin sum over numpy integers accumulates in the input dtype without
29+
# promotion (NEP 50), so large groups silently wrap - python ints keep
30+
# the per-group accumulation exact, floats keep the builtin behaviour
31+
kind = getattr(getattr(x[0], "dtype", None), "kind", None)
32+
if kind in ("i", "u"):
33+
return sum(int(val) for val in x)
34+
return sum(x)
35+
36+
2637
def _last(x: Sequence[Any]) -> Any:
2738
return x[-1]
2839

@@ -136,7 +147,7 @@ def reduction_with_nan(values):
136147
_impl_dict = {
137148
"min": _propagate_nan(min),
138149
"max": _propagate_nan(max),
139-
"sum": sum,
150+
"sum": _sum,
140151
"prod": _prod,
141152
"last": _last,
142153
"first": _first,

‎numpy_groupies/tests/test_generic.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88

99
from .. import aggregate_np
10-
from ..utils import default_fill_value
10+
from ..utils import default_fill_value, minimum_dtype
1111
from . import (
1212
_deselect_not_implemented,
1313
_deselect_purepy,
@@ -31,6 +31,35 @@ def test_preserve_missing(aggregate_all):
3131
assert "int" in res.dtype.name
3232

3333

34+
@pytest.mark.parametrize("dtype", [np.int8, np.uint8, np.int16, np.uint16, np.int32, np.uint32])
35+
def test_sum_dtype_guess_survives_single_group(aggregate_all, dtype):
36+
# a single group receives every value - the sum overflow guess has to be
37+
# based on the input length, not on the (here smaller) number of output
38+
# groups, or the result silently wraps in the input dtype
39+
maximum = int(np.iinfo(dtype).max)
40+
group_idx = np.array([0, 0])
41+
a = np.array([maximum, maximum], dtype=dtype)
42+
# detected output size: one single group - the worst case for the guess
43+
res = aggregate_all(group_idx, a, func="sum")
44+
np.testing.assert_array_equal(res, [2 * maximum])
45+
# an explicitly larger output size with fill values must not change this
46+
res = aggregate_all(group_idx, a, func="sum", size=3, fill_value=-1)
47+
np.testing.assert_array_equal(res, [2 * maximum, -1, -1])
48+
if not isinstance(res, list):
49+
assert res.dtype == minimum_dtype(2 * maximum, np.dtype(dtype))
50+
51+
52+
def test_sum_bool_dtype_guess_ignores_empty_groups(aggregate_all):
53+
# empty groups inflate the output size, but never the number of values
54+
# that get summed into one group - the bool sum guess stays narrow
55+
res = aggregate_all(np.array([0, 0]), np.array([True, True]), func="sum", size=300)
56+
values = np.asarray(res)
57+
assert values[0] == 2
58+
assert not values[1:].any()
59+
if not isinstance(res, list):
60+
assert res.dtype == np.int8
61+
62+
3463
@pytest.mark.parametrize("group_idx_type", [int, "uint32", "uint64"])
3564
def test_uint_group_idx(aggregate_all, group_idx_type):
3665
group_idx = np.array([1, 1, 2, 2, 2, 2, 4, 4], dtype=group_idx_type)

0 commit comments

Comments
 (0)