labelarray 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
labelarray/labels.py ADDED
@@ -0,0 +1,244 @@
1
+ """One exact-label rule for every path that compares labels.
2
+
3
+ Labels on an axis match only when they are the same kind (booleans, numbers,
4
+ times, durations, text, bytes) and exactly equal: a label is never converted
5
+ in a way that changes it to make a match. The integer ``2**53 + 1`` does not
6
+ match the float ``2**53``, a half-second does not match whole seconds, and
7
+ ``True`` does not match ``1``. A missing label (NaN or NaT) matches a missing
8
+ label of the same kind. Alignment and joins use these functions on both
9
+ backends, so they agree whatever the label types or their order; the label
10
+ indexes (``labelarray.index``) convert lookups with ``exact_cast`` too.
11
+
12
+ This module needs NumPy only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import warnings
18
+ from collections import Counter
19
+ from collections.abc import Hashable
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+
24
+ from labelarray.errors import LabelError
25
+
26
+ # Label kinds that can match each other. Booleans, numbers, times, durations,
27
+ # text, and bytes never match across kinds, as in pandas: True is not 1.
28
+ KINDS = {"b": "bool", "i": "number", "u": "number", "f": "number", "c": "number",
29
+ "M": "time", "m": "duration", "U": "text", "S": "bytes"}
30
+
31
+ # Kinds registered with an index (``labelarray.index.register_index``), by the Python types
32
+ # their labels have. Such labels match only labels of the same kind, like the kinds above.
33
+ _REGISTERED_TYPES: dict[str, tuple[type, ...]] = {}
34
+
35
+
36
+ def kind(values: np.ndarray, op: str = "index", dim: Hashable = None) -> tuple[str, np.ndarray]:
37
+ """The label kind, with object arrays of one Python type converted to it.
38
+
39
+ Object labels whose types a registered kind names (``register_types``)
40
+ are that kind, and stay objects.
41
+ """
42
+ values = np.asarray(values)
43
+ if values.dtype.kind != "O":
44
+ return KINDS.get(values.dtype.kind, "other"), values
45
+ if values.size == 0:
46
+ return "other", values
47
+ types = {type(v) for v in values.tolist()}
48
+ for name, accepted, convert in (
49
+ ("text", (str,), str), ("bytes", (bytes,), bytes), ("bool", (bool, np.bool_), bool),
50
+ ):
51
+ if all(issubclass(t, accepted) for t in types):
52
+ return name, np.array(values.tolist(), dtype=convert)
53
+ if all(issubclass(t, (int, float, complex, np.number)) and not issubclass(t, (bool, np.bool_)) for t in types):
54
+ return "number", np.array(values.tolist())
55
+ for name, accepted in _REGISTERED_TYPES.items():
56
+ if all(issubclass(t, accepted) for t in types):
57
+ return name, values
58
+ names = ", ".join(sorted(t.__name__ for t in types))
59
+ raise LabelError(f"{op}: labels on {dim!r} mix types ({names}), so they cannot be ordered or looked up; "
60
+ "give the axis labels of one type")
61
+
62
+
63
+ def register_types(name: str, types: tuple[type, ...]) -> None:
64
+ """Make object labels of *types* the kind *name*, or forget *name* when *types* is empty."""
65
+ if name in KINDS.values():
66
+ raise ValueError(f"{name} is a built-in label kind; types name the labels of a new kind")
67
+ _REGISTERED_TYPES.pop(name, None)
68
+ if types:
69
+ _REGISTERED_TYPES[name] = tuple(types)
70
+
71
+
72
+ def missing(values: np.ndarray) -> np.ndarray:
73
+ if values.dtype.kind in "fc":
74
+ return np.isnan(values)
75
+ if values.dtype.kind in "mM":
76
+ return np.isnat(values)
77
+ return np.zeros(values.shape, dtype=bool)
78
+
79
+
80
+ def exact_cast(wanted: np.ndarray, dtype: np.dtype) -> tuple[np.ndarray, np.ndarray]:
81
+ """*wanted* in *dtype*, and where that conversion is exact.
82
+
83
+ A value that changes on the way, such as float64 0.1 as float32, a
84
+ half-second as whole seconds, or -1 as an unsigned integer, is not
85
+ exact; nothing is rounded to make a match. Missing values are not exact.
86
+ """
87
+ with np.errstate(all="ignore"), warnings.catch_warnings():
88
+ warnings.simplefilter("ignore")
89
+ cast = wanted.astype(dtype)
90
+ exact = cast.astype(wanted.dtype) == wanted
91
+ if dtype.kind in "iu" and wanted.dtype.kind == "f":
92
+ # A float beyond the integers saturates on some machines (2.0**63 becomes 2**63 - 1 on
93
+ # ARM), and the saturated value rounds back to the float, so the round trip cannot tell.
94
+ # The limits are powers of two, exact as floats.
95
+ info = np.iinfo(dtype)
96
+ exact &= (wanted >= float(info.min)) & (wanted < float(info.max + 1))
97
+ elif dtype.kind == "u" and wanted.dtype.kind == "i":
98
+ exact &= wanted >= 0
99
+ elif dtype.kind == "i" and wanted.dtype.kind == "u":
100
+ exact &= wanted <= np.iinfo(dtype).max
101
+ return cast, np.asarray(exact, dtype=bool)
102
+
103
+
104
+ def equal(a: Any, b: Any) -> bool:
105
+ """The same labels in the same order; a missing label equals a missing label."""
106
+ if a is b:
107
+ return True
108
+ a, b = np.asarray(a), np.asarray(b)
109
+ if len(a) != len(b):
110
+ return False
111
+ if not len(a):
112
+ return True
113
+ try:
114
+ kind_a, a = kind(a)
115
+ kind_b, b = kind(b)
116
+ except LabelError:
117
+ return _keys(a) == _keys(b)
118
+ if kind_a != kind_b:
119
+ return False
120
+ if kind_a == "other":
121
+ return _keys(a) == _keys(b)
122
+ gaps_a, gaps_b = missing(a), missing(b)
123
+ if not np.array_equal(gaps_a, gaps_b):
124
+ return False
125
+ if gaps_a.any():
126
+ a, b = a[~gaps_a], b[~gaps_b]
127
+ if a.dtype == b.dtype:
128
+ return bool(np.array_equal(a, b))
129
+ cast, exact = exact_cast(b, a.dtype)
130
+ return bool(exact.all() and np.array_equal(cast, a))
131
+
132
+
133
+ def compare(a: Any, b: Any, op: str = "align", dim: Hashable = None, *,
134
+ mixed: bool = False) -> tuple[str, int, int]:
135
+ """``("same", 0, 0)``, ``("reordered", 0, 0)``, or ``("different", only in a, only in b)``.
136
+
137
+ "Reordered" means both hold the same labels as many times each. Counts
138
+ are of distinct labels. Labels of mixed Python types are an error naming
139
+ *op* and *dim*, unless *mixed* is true; then they are compared one by
140
+ one, each with its own kind.
141
+ """
142
+ if equal(a, b):
143
+ return "same", 0, 0
144
+ a, b = np.asarray(a), np.asarray(b)
145
+ try:
146
+ kind_a, values_a = kind(a, op, dim)
147
+ kind_b, values_b = kind(b, op, dim)
148
+ except LabelError:
149
+ if not mixed:
150
+ raise
151
+ kind_a = kind_b = "other"
152
+ if kind_a == "other" or kind_b == "other":
153
+ keys_a, keys_b = _keys(a), _keys(b)
154
+ if Counter(keys_a) == Counter(keys_b):
155
+ return "reordered", 0, 0
156
+ return "different", len(set(keys_a) - set(keys_b)), len(set(keys_b) - set(keys_a))
157
+ if len(a) == len(b) and kind_a == kind_b and _same_multiset(values_a, values_b):
158
+ return "reordered", 0, 0
159
+ found_a, found_b = _found(kind_a, values_a, kind_b, values_b), _found(kind_b, values_b, kind_a, values_a)
160
+ return "different", _distinct(values_a[~found_a]), _distinct(values_b[~found_b])
161
+
162
+
163
+ def strict_subset(part: Any, whole: Any, op: str = "align", dim: Hashable = None, *, mixed: bool = False) -> bool:
164
+ """Whether *part*'s labels are some, but not all, of *whole*'s."""
165
+ state, only_part, only_whole = compare(part, whole, op, dim, mixed=mixed)
166
+ return state == "different" and only_part == 0 and only_whole > 0
167
+
168
+
169
+ def lossy_join_type(a: Any, b: Any) -> np.dtype | None:
170
+ """The type a join would hold both label arrays in, when it cannot hold them all exactly.
171
+
172
+ Joined labels take NumPy's common type, so int64 labels joined with
173
+ float64 ones become float64; ``2**53 + 1`` cannot survive that, and a
174
+ join would then pair it with ``2**53``. None when every label survives.
175
+ """
176
+ a, b = np.asarray(a), np.asarray(b)
177
+ if a.dtype == b.dtype:
178
+ return None
179
+ try:
180
+ common = np.result_type(a.dtype, b.dtype)
181
+ except TypeError:
182
+ return None
183
+ if common.kind == "O":
184
+ return None
185
+ for values in (a, b):
186
+ if values.dtype != common:
187
+ _, exact = exact_cast(values, common)
188
+ if not (exact | missing(values)).all():
189
+ return common
190
+ return None
191
+
192
+
193
+ def _found(kind_a: str, a: np.ndarray, kind_b: str, b: np.ndarray) -> np.ndarray:
194
+ """Which labels of *a* are among *b*'s, exactly."""
195
+ found = np.zeros(len(a), dtype=bool)
196
+ if kind_a != kind_b or not len(a) or not len(b):
197
+ return found
198
+ gaps_a, gaps_b = missing(a), missing(b)
199
+ found[gaps_a] = gaps_b.any()
200
+ rest = ~gaps_a
201
+ present_a, present_b = a[rest], b[~gaps_b]
202
+ if present_a.dtype == present_b.dtype:
203
+ found[rest] = np.isin(present_a, present_b)
204
+ else:
205
+ cast, exact = exact_cast(present_a, present_b.dtype)
206
+ found[rest] = exact & np.isin(cast, present_b)
207
+ return found
208
+
209
+
210
+ def _same_multiset(a: np.ndarray, b: np.ndarray) -> bool:
211
+ """Whether *a* and *b*, of one kind and length, hold the same labels as many times each."""
212
+ gaps_a, gaps_b = missing(a), missing(b)
213
+ if gaps_a.sum() != gaps_b.sum():
214
+ return False
215
+ a, b = a[~gaps_a], b[~gaps_b]
216
+ if a.dtype != b.dtype:
217
+ b, exact = exact_cast(b, a.dtype)
218
+ if not exact.all():
219
+ return False
220
+ return bool(np.array_equal(np.sort(a), np.sort(b)))
221
+
222
+
223
+ def _distinct(values: np.ndarray) -> int:
224
+ if not len(values):
225
+ return 0
226
+ gaps = missing(values)
227
+ return len(np.unique(values[~gaps])) + int(gaps.any())
228
+
229
+
230
+ def _keys(values: np.ndarray) -> list:
231
+ """Labels as hashable Python values tagged with their kind, a missing label as one key.
232
+
233
+ Python compares its numbers exactly (``2**53 + 1 != float(2**53)``);
234
+ the tag keeps ``True`` apart from ``1``.
235
+ """
236
+ return [("missing",) if v != v else (_tag(v), v) for v in np.asarray(values).tolist()]
237
+
238
+
239
+ def _tag(value: Any) -> str:
240
+ if isinstance(value, (bool, np.bool_)):
241
+ return "bool"
242
+ if isinstance(value, (int, float, complex, np.number)):
243
+ return "number"
244
+ return type(value).__name__
labelarray/py.typed ADDED
File without changes
labelarray/sums.py ADDED
@@ -0,0 +1,317 @@
1
+ """Float32 sums and means: float64 accumulation, rounded once to float32.
2
+
3
+ Two ways, chosen by how many values each result adds:
4
+
5
+ - Every grouped reduction, and every ungrouped one adding ``FEW`` (8) or more
6
+ values per result: the result is correctly rounded, the float32 nearest
7
+ the exact sum or mean of the stored values, ties to even. A float64 sum
8
+ and an error bound settle almost every result; where the bound cannot,
9
+ the values are added exactly (``_exact``). A correctly rounded result is
10
+ unique, so every memory layout, summation order, kernel (NumPy or the
11
+ optional compiled one), thread count, and backend gives the same bits.
12
+ - Ungrouped reductions adding fewer than ``FEW`` values per result, such as
13
+ ``sum(p**2, over="i")`` over x, y, and z: the values are added in float64
14
+ in a fixed order, first to last along the reduced axes, and rounded once.
15
+ The order does not depend on memory layout; the compiled kernel, when
16
+ installed, adds in the same order in one pass. Checking each of these results
17
+ would cost several times the sum itself (the float64 sum of a few float32
18
+ values is often exact and exactly halfway between two float32 values, where
19
+ a bound cannot decide), so cancellation among those few values is rounded
20
+ in float64 as it goes: ``[2**53, 1, -2**53]`` sums to 0 here, not 1.
21
+
22
+ Missing values: without ``na_rm`` a NaN makes its result NaN; with it, NaN
23
+ is skipped and a mean divides by the values present. +inf and -inf together
24
+ give NaN. A sum of no values is +0.0 and a mean NaN; a zero sum is +0.0, as
25
+ NumPy gives it. ``min_count`` makes a sum NaN where fewer values are present.
26
+ This module needs NumPy only.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import math
32
+ from collections.abc import Sequence
33
+ from fractions import Fraction
34
+
35
+ import numpy as np
36
+
37
+ FEW = 8
38
+ COMPILED = True # use the optional compiled kernels when Numba is installed
39
+ # From these many values; below them, calling compiled code costs more than it saves. Grouped sums: 34,688
40
+ # values took 201 µs compiled against 155, 300,000 took 322 against 1,231. Few-value sums: 180,000 values
41
+ # 89 against 78 µs, 300,000 103 against 118. Both paths give the same bits.
42
+ COMPILED_FROM = 100_000
43
+ IN_ORDER_COMPILED_FROM = 300_000
44
+
45
+ _U = 2.0**-53 # float64 unit roundoff
46
+ # Sums are made in chunks of positions, then the chunk totals added, so a result's error bound grows
47
+ # with the chunk length plus the number of chunks rather than with every position: about sqrt(n) chunks
48
+ # of sqrt(n) positions, with at most this many (chunk, group) bins at once.
49
+ _BINS = 1 << 22
50
+ _MAX32 = Fraction(float(np.finfo(np.float32).max))
51
+
52
+
53
+ def reduce(values: np.ndarray, op: str, axes: Sequence[int], *, na_rm: bool = False,
54
+ min_count: int | None = None, codes: np.ndarray | None = None, groups: int = 0,
55
+ sizes: np.ndarray | None = None) -> np.ndarray:
56
+ """*op* (``"sum"`` or ``"mean"``) of float32 *values* over the axis numbers *axes*.
57
+
58
+ Without *codes*, the result has the kept axes in their order. With
59
+ *codes* (one group code from 0 to ``groups - 1`` per position along
60
+ ``axes[0]``), it has one row per group first: ``(groups, *kept)``;
61
+ *sizes*, positions per group along ``axes[0]``, may be passed when known.
62
+ """
63
+ values = np.asarray(values)
64
+ if values.dtype != np.float32:
65
+ raise TypeError(f"float32 values expected, got {values.dtype}")
66
+ axes = tuple(int(a) for a in axes)
67
+ kept = tuple(a for a in range(values.ndim) if a not in axes)
68
+ kept_shape = tuple(values.shape[a] for a in kept)
69
+ positions = math.prod(values.shape[a] for a in axes)
70
+ with np.errstate(all="ignore"):
71
+ if codes is None and positions < FEW:
72
+ compiled = (_compiled("in_order") if COMPILED and len(axes) == 1 and values.ndim <= 2
73
+ and values.size >= IN_ORDER_COMPILED_FROM else None)
74
+ if compiled is not None:
75
+ # A view: one reduced axis, at most one kept; the width is explicit, since -1 is ambiguous when empty.
76
+ table = np.moveaxis(values, axes[0], 0).reshape(positions, math.prod(kept_shape))
77
+ result, present = compiled(table, op == "mean", na_rm)
78
+ if op == "sum" and min_count is not None:
79
+ result = np.where(present >= min_count, result, np.float32(np.nan))
80
+ return result.reshape(kept_shape)
81
+ return _in_order(values, axes, kept_shape, op, na_rm, min_count)
82
+ table = np.transpose(values, axes + kept).reshape(positions, math.prod(kept_shape))
83
+ if codes is not None:
84
+ others = math.prod(values.shape[a] for a in axes[1:])
85
+ rows = np.asarray(codes) if others == 1 else np.repeat(np.asarray(codes), others)
86
+ if sizes is not None and others != 1:
87
+ sizes = np.asarray(sizes) * others
88
+ result = _settled(table, rows, groups, sizes, op, na_rm, min_count)
89
+ return result.reshape((groups, *kept_shape))
90
+ return _settled(table, None, 1, None, op, na_rm, min_count).reshape(kept_shape)
91
+
92
+
93
+ def _in_order(values, axes, kept_shape, op, na_rm, min_count) -> np.ndarray:
94
+ """Few values per result: float64, added first to last along *axes*, rounded once.
95
+
96
+ Each position along the reduced axes is a view of the input, added in
97
+ place, so no copy is made whatever the layout; missing values are
98
+ counted only when ``na_rm`` or ``min_count`` needs them.
99
+ """
100
+ moved = np.moveaxis(values, axes, tuple(range(len(axes))))
101
+ total = np.zeros(kept_shape)
102
+ counting = na_rm or (op == "sum" and min_count is not None)
103
+ present = np.zeros(kept_shape, dtype=np.intp) if counting else None
104
+ for index in np.ndindex(moved.shape[:len(axes)]):
105
+ row = moved[index]
106
+ if counting:
107
+ missing = np.isnan(row)
108
+ present += ~missing
109
+ if na_rm:
110
+ row = np.where(missing, np.float32(0), row)
111
+ np.add(total, row, out=total)
112
+ terms = math.prod(values.shape[a] for a in axes)
113
+ return _finish(total, present, terms, op, na_rm, min_count)
114
+
115
+
116
+ def _finish(total, present, terms, op, na_rm, min_count) -> np.ndarray:
117
+ if op == "mean":
118
+ np.divide(total, present if na_rm else terms, out=total)
119
+ return total.astype(np.float32)
120
+ result = total.astype(np.float32)
121
+ return np.where(present >= min_count, result, np.float32(np.nan)) if min_count is not None else result
122
+
123
+
124
+ # ── Correctly rounded: an estimate, a bound, and exact sums where needed ────
125
+
126
+
127
+ def _settled(table, rows, groups, sizes, op, na_rm, min_count) -> np.ndarray:
128
+ """Correctly rounded results, one row per group (one row in all when *rows* is None)."""
129
+ total, magnitude, present, values, terms, compensated = _estimate(table, rows, groups, sizes, na_rm)
130
+ counts = present if na_rm else values
131
+ result, sure = _certify(total, magnitude, terms, counts, op, compensated)
132
+ unsure = ~sure
133
+ for column in np.flatnonzero(unsure.any(axis=0)):
134
+ chosen = np.flatnonzero(unsure[:, column])
135
+ result[chosen, column] = _exact(table[:, column], rows, chosen, groups, op, na_rm)
136
+ if op == "sum" and min_count is not None:
137
+ result = np.where(present >= min_count, result, np.float32(np.nan))
138
+ return result
139
+
140
+
141
+ def _estimate(table, rows, groups, sizes, na_rm):
142
+ """Float64 sums, sums of magnitudes, values present, values, and terms for the bound, per group and column.
143
+
144
+ The NumPy path adds chunks of positions (one ``np.bincount`` over
145
+ (chunk, group) bins, or ``np.add.reduceat`` without groups) and then the
146
+ chunk totals, so a result's error grows with the chunk length plus the
147
+ number of chunks; "terms" is that effective count, for the bound only.
148
+ """
149
+ positions, width = table.shape
150
+ chunks = max(1, min(math.isqrt(positions), _BINS // max(groups, 1)))
151
+ length = -(-positions // chunks) if positions else 0
152
+ if rows is None:
153
+ missing = np.isnan(table)
154
+ weights = np.where(missing, np.float32(0), table) if na_rm else table
155
+ if positions:
156
+ starts = np.arange(0, positions, length)
157
+ total = np.add.reduceat(weights, starts, axis=0, dtype=np.float64).sum(axis=0)[None]
158
+ magnitude = np.add.reduceat(np.abs(weights), starts, axis=0, dtype=np.float64).sum(axis=0)[None]
159
+ else:
160
+ total = magnitude = np.zeros((1, width))
161
+ present = (positions - missing.sum(axis=0))[None]
162
+ terms = np.full((1, width), min(positions, length + chunks))
163
+ return total, magnitude, present, np.full((1, width), positions), terms, False
164
+ if sizes is None:
165
+ sizes = np.bincount(rows, minlength=groups)
166
+ sizes = np.asarray(sizes)
167
+ kernel = _compiled("grouped_estimate") if COMPILED and table.size >= COMPILED_FROM else None
168
+ if kernel is not None:
169
+ total, magnitude, present = kernel(table, np.ascontiguousarray(rows, dtype=np.intp), groups, na_rm)
170
+ values = np.repeat(sizes[:, None], width, axis=1)
171
+ return total, magnitude, present, values, values, True
172
+ effective = np.minimum(sizes, length) + np.minimum(sizes, chunks) # additions within a chunk, then of chunk totals
173
+ terms = np.repeat(np.minimum(sizes, effective)[:, None], width, axis=1)
174
+ key = rows if chunks == 1 else (np.arange(positions) // length) * groups + rows
175
+ bins = chunks * groups
176
+ total = np.empty((groups, width))
177
+ magnitude = np.empty((groups, width))
178
+ present = np.empty((groups, width), dtype=np.intp)
179
+ for column in range(width):
180
+ values = table[:, column]
181
+ missing = np.isnan(values)
182
+ anything_missing = bool(missing.any())
183
+ weights = np.where(missing, np.float32(0), values) if na_rm and anything_missing else values
184
+ total[:, column] = np.bincount(key, weights=weights, minlength=bins).reshape(chunks, groups).sum(axis=0)
185
+ magnitude[:, column] = np.bincount(key, weights=np.abs(weights), minlength=bins).reshape(chunks, groups).sum(axis=0)
186
+ present[:, column] = (sizes - np.bincount(rows, weights=missing, minlength=groups).astype(np.intp)
187
+ if anything_missing else sizes)
188
+ return total, magnitude, present, np.repeat(sizes[:, None], width, axis=1), terms, False
189
+
190
+
191
+ def _certify(total, magnitude, terms, counts, op, compensated):
192
+ """Results where the error bound proves the rounding, and where it does.
193
+
194
+ A float64 sum of n values, in any order, is within gamma(n) * sum|x| of
195
+ the exact sum, gamma(n) = n u / (1 - n u); a compensated one within
196
+ (6u + 4 (n u)^2) * sum|x|. Where both ends of the interval round to the
197
+ same float32, the exact value rounds there too.
198
+ """
199
+ n = terms.astype(np.float64)
200
+ gamma = n * _U / (1 - n * _U)
201
+ size = magnitude / (1 - gamma) # the computed magnitude may itself be low by that factor
202
+ bound = (6 * _U + 4 * (n * _U) ** 2) * size if compensated else gamma * size
203
+ bound = bound * (1 + 8 * _U) + np.abs(total) * (2 * _U) + 1e-300 # the bound's and the ends' own rounding
204
+ if op == "mean":
205
+ centre = total / counts
206
+ bound = bound / counts * (1 + 4 * _U) + np.abs(centre) * (4 * _U)
207
+ else:
208
+ centre = total
209
+ low, high = (centre - bound).astype(np.float32), (centre + bound).astype(np.float32)
210
+ sure = (low == high) & np.isfinite(bound) & np.isfinite(centre)
211
+ if op == "mean":
212
+ sure &= counts > 0
213
+ return high, sure
214
+
215
+
216
+ def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
217
+ """Correctly rounded results for groups *chosen*, from exact sums of their values."""
218
+ if rows is None:
219
+ part, codes, count = values, np.zeros(len(values), dtype=np.intp), 1
220
+ else:
221
+ remap = np.full(groups, -1, dtype=np.intp)
222
+ remap[chosen] = np.arange(len(chosen))
223
+ codes = remap[rows]
224
+ keep = codes >= 0
225
+ part, codes, count = values[keep], codes[keep], len(chosen)
226
+ totals, present, special = _exact_sums(part, codes, count, na_rm)
227
+ out = np.empty(count, dtype=np.float32)
228
+ for k in range(count):
229
+ if special[k] is not None:
230
+ out[k] = special[k]
231
+ elif op == "sum":
232
+ out[k] = _round32(totals[k])
233
+ else:
234
+ out[k] = _round32(totals[k] / present[k]) if present[k] else np.float32(np.nan)
235
+ if op == "mean":
236
+ out[(present == 0) & np.array([s is None for s in special])] = np.nan
237
+ return out
238
+
239
+
240
+ def _exact_sums(values, codes, count, na_rm):
241
+ """Exact sums as Fractions, values present, and NaN or infinity where they decide the result.
242
+
243
+ A float32 is M * 2**(e - 150) with M a signed integer below 2**24 and e
244
+ from 1 to 254. Integers M with the same e add exactly in float64 for
245
+ fewer than 2**29 values, so they are summed per group and exponent with
246
+ ``np.bincount``, and the few totals per group combined in Python integers.
247
+ """
248
+ bits = values.view(np.uint32)
249
+ exponent = ((bits >> 23) & 0xFF).astype(np.intp)
250
+ nan = np.isnan(values)
251
+ infinite = np.isinf(values)
252
+ finite = ~(nan | infinite)
253
+ mantissa = (bits & 0x7FFFFF).astype(np.int64) | np.where(exponent > 0, 1 << 23, 0)
254
+ mantissa = np.where(bits >> 31, -mantissa, mantissa)
255
+ exponent = np.maximum(exponent, 1)
256
+ present = np.bincount(codes, minlength=count) - np.bincount(codes, weights=nan, minlength=count).astype(np.intp)
257
+ has_nan = np.bincount(codes, weights=nan, minlength=count) > 0
258
+ plus = np.bincount(codes, weights=values == np.inf, minlength=count) > 0
259
+ minus = np.bincount(codes, weights=values == -np.inf, minlength=count) > 0
260
+ totals = [Fraction(0)] * count
261
+ if finite.any():
262
+ low, high = int(exponent[finite].min()), int(exponent[finite].max())
263
+ width = high - low + 1
264
+ key = codes[finite] * width + (exponent[finite] - low)
265
+ weights = mantissa[finite].astype(np.float64)
266
+ buckets = np.zeros(count * width, dtype=np.int64)
267
+ chunk = 1 << 28 # each bucket sum stays below 2**52, so float64 holds it exactly
268
+ for start in range(0, len(key), chunk):
269
+ buckets += np.bincount(key[start:start + chunk], weights=weights[start:start + chunk],
270
+ minlength=count * width).astype(np.int64)
271
+ buckets = buckets.reshape(count, width)
272
+ scale = Fraction(2) ** (low - 150)
273
+ for k in range(count):
274
+ row = buckets[k]
275
+ whole = sum(int(row[j]) << int(j) for j in np.flatnonzero(row)) # Python integers: shifts beyond 63
276
+ totals[k] = whole * scale
277
+ special = [None] * count
278
+ for k in range(count):
279
+ if (has_nan[k] and not na_rm) or (plus[k] and minus[k]):
280
+ special[k] = np.float32(np.nan)
281
+ elif plus[k] or minus[k]:
282
+ special[k] = np.float32(np.inf if plus[k] else -np.inf)
283
+ return totals, present, special
284
+
285
+
286
+ def _round32(value: Fraction) -> np.float32:
287
+ """*value* correctly rounded to float32, ties to even."""
288
+ if value == 0:
289
+ return np.float32(0.0)
290
+ if abs(value) >= _MAX32 + Fraction(2) ** 103: # half a step above the largest float32
291
+ return np.float32(math.inf if value > 0 else -math.inf)
292
+ guess = np.float32(float(value)) # float() rounds correctly; the cast can round a second time
293
+ best = guess
294
+ for candidate in (np.nextafter(guess, np.float32(-np.inf)), np.nextafter(guess, np.float32(np.inf))):
295
+ if not np.isfinite(candidate):
296
+ continue
297
+ gap, best_gap = abs(Fraction(float(candidate)) - value), abs(Fraction(float(best)) - value)
298
+ if gap < best_gap or (gap == best_gap and int(candidate.view(np.uint32)) % 2 == 0):
299
+ best = candidate
300
+ return np.float32(best)
301
+
302
+
303
+ # ── The optional compiled kernel ────────────────────────────────────────────
304
+
305
+ _KERNELS: dict = {}
306
+
307
+
308
+ def _compiled(name: str):
309
+ """A compiled kernel from ``_compiled`` when Numba is installed (``labelarray[fast]``), else None."""
310
+ if name not in _KERNELS:
311
+ try:
312
+ from labelarray import _compiled as module
313
+ except ImportError:
314
+ _KERNELS[name] = None
315
+ else:
316
+ _KERNELS[name] = getattr(module, name)
317
+ return _KERNELS[name]