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/__init__.py +82 -0
- labelarray/__version__.py +1 -0
- labelarray/_compiled.py +101 -0
- labelarray/align.py +40 -0
- labelarray/array.py +474 -0
- labelarray/collection.py +267 -0
- labelarray/contract.py +47 -0
- labelarray/errors.py +21 -0
- labelarray/grouped.py +259 -0
- labelarray/index.py +470 -0
- labelarray/labels.py +244 -0
- labelarray/py.typed +0 -0
- labelarray/sums.py +317 -0
- labelarray/transforms.py +610 -0
- labelarray-0.1.0.dist-info/METADATA +187 -0
- labelarray-0.1.0.dist-info/RECORD +19 -0
- labelarray-0.1.0.dist-info/WHEEL +5 -0
- labelarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- labelarray-0.1.0.dist-info/top_level.txt +1 -0
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]
|