labelarray 0.1.0__tar.gz → 0.1.2__tar.gz
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-0.1.2/CHANGELOG.md +58 -0
- {labelarray-0.1.0/src/labelarray.egg-info → labelarray-0.1.2}/PKG-INFO +5 -3
- {labelarray-0.1.0 → labelarray-0.1.2}/README.md +4 -2
- labelarray-0.1.2/src/labelarray/__version__.py +1 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/sums.py +266 -34
- {labelarray-0.1.0 → labelarray-0.1.2/src/labelarray.egg-info}/PKG-INFO +5 -3
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_sums.py +204 -2
- labelarray-0.1.0/CHANGELOG.md +0 -25
- labelarray-0.1.0/src/labelarray/__version__.py +0 -1
- {labelarray-0.1.0 → labelarray-0.1.2}/LICENSE +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/MANIFEST.in +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/docs/releasing.md +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/pyproject.toml +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/scripts/check.sh +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/setup.cfg +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/__init__.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/_compiled.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/align.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/array.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/collection.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/contract.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/errors.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/grouped.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/index.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/labels.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/py.typed +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/transforms.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray.egg-info/SOURCES.txt +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray.egg-info/dependency_links.txt +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray.egg-info/requires.txt +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray.egg-info/top_level.txt +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_array.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_collection.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_contraction.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_grouping.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_label_indexes.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_label_lookups.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_library_errors.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_readme.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_standalone.py +0 -0
- {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_transforms.py +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2 — 2026-10-26
|
|
4
|
+
|
|
5
|
+
- Float32 sums and means of data held in chunks (Dask) can be correctly
|
|
6
|
+
rounded too, the same bits as `sums.reduce` gives in memory whatever the
|
|
7
|
+
chunking. `sums.chunk_estimate` gives one chunk's float64 totals, sums of
|
|
8
|
+
magnitudes, counts (values, values present, +inf, -inf), and the term count
|
|
9
|
+
of its error bound, which combine across chunks in any order; `settle`
|
|
10
|
+
certifies the combined estimate; `chunk_exact` gives one chunk's exact
|
|
11
|
+
sums for the groups the bound could not settle, as int64 buckets per
|
|
12
|
+
float32 exponent, which add across chunks; `exact_results` rounds them;
|
|
13
|
+
`from_chunks` drives it all given a function returning the exact sums.
|
|
14
|
+
Chunks take the NumPy estimate, since they already run in parallel.
|
|
15
|
+
- Exact sums are measured from each group's own lowest nonzero exponent, so
|
|
16
|
+
a group whose values span few exponents fits in 64-bit integers whatever
|
|
17
|
+
its magnitude; a group like `[2**100, 1, -2**100]` no longer goes through
|
|
18
|
+
Python fractions. Results are unchanged, bit for bit.
|
|
19
|
+
|
|
20
|
+
## 0.1.1 — 2026-10-26
|
|
21
|
+
|
|
22
|
+
- Grouped float32 sums and means over many small groups are much faster:
|
|
23
|
+
200,000 values in 96,846 groups (voxels holding one to a few points) now
|
|
24
|
+
take 6 ms instead of 111. The exact mean of a few float32 values often
|
|
25
|
+
lies exactly halfway between two float32 values, where the error bound
|
|
26
|
+
cannot decide the rounding; about one group in ten went to an exact
|
|
27
|
+
path computed in Python fractions. Groups whose values span a narrow
|
|
28
|
+
range of exponents now have their exact sums computed as 64-bit integers
|
|
29
|
+
and rounded with NumPy (integer division kept to 27 or 28 bits with the
|
|
30
|
+
last bit set when anything is left over, then one rounding to float32),
|
|
31
|
+
so the Python path is left for groups spanning a wide range. Results are
|
|
32
|
+
unchanged, bit for bit: the tests compare them with exact rational
|
|
33
|
+
arithmetic, and the new code gave the same bits as 0.1.0 on two million
|
|
34
|
+
results.
|
|
35
|
+
|
|
36
|
+
## 0.1.0 — 2026-10-25
|
|
37
|
+
|
|
38
|
+
The first release on its own. labelarray was developed as the array layer
|
|
39
|
+
of tidy3a (`tidy3a.labelarray`) and leaves it unchanged in behaviour:
|
|
40
|
+
|
|
41
|
+
- `Array`: a NumPy array with named axes, exact labels, attributes, and a
|
|
42
|
+
name. Selection by positions and labels (`isel`, `sel`), `transpose`,
|
|
43
|
+
`rename`, reductions (`reduce`), and contraction (`dot`). NumPy's ufuncs,
|
|
44
|
+
`np.where`, and Python's operators broadcast by axis name under the exact
|
|
45
|
+
alignment rule.
|
|
46
|
+
- `Collection`: arrays sharing axes, one label array per axis, with
|
|
47
|
+
selection, assignment, joins (`join`), nearest-label matching
|
|
48
|
+
(`nearest`), and conversion to and from xarray.
|
|
49
|
+
- The exact label rule (`labels`) and label indexes: sorted, sort-once,
|
|
50
|
+
and grid indexes, chosen by `index_for`, with `register_index` for other
|
|
51
|
+
label types.
|
|
52
|
+
- Grouping: `Grouping`, `factorize`, the key transforms `Bins`,
|
|
53
|
+
`Duration`, `Round`, and `GridCells`, and grouped reductions
|
|
54
|
+
(`grouped.reduce`, `counts`, `broadcast`) bit-identical to reducing each
|
|
55
|
+
group on its own.
|
|
56
|
+
- Float32 sums and means correctly rounded in any memory layout (`sums`),
|
|
57
|
+
with an optional compiled path (`labelarray[fast]`) giving the same bits.
|
|
58
|
+
- One family of errors under `LabelArrayError`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labelarray
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Labelled N-D arrays in pure NumPy, with exact label semantics and reproducible reductions
|
|
5
5
|
Author: Rigoberto Leyva Salmeron
|
|
6
6
|
License-Expression: MIT
|
|
@@ -42,11 +42,13 @@ It is deliberately small. What it offers that other array libraries do not:
|
|
|
42
42
|
2. **Reproducible reductions.** Float32 sums and means are correctly
|
|
43
43
|
rounded: the float32 nearest the exact result, the same in any memory
|
|
44
44
|
layout. Grouped reductions are bit-identical to reducing each group on
|
|
45
|
-
its own
|
|
45
|
+
its own, and data held in chunks (Dask) gets the same bits whatever the
|
|
46
|
+
chunking: each chunk's estimate combines in any order, and exact sums
|
|
47
|
+
settle the few results an error bound cannot.
|
|
46
48
|
3. **Collections.** Arrays sharing axes, with one label array per axis,
|
|
47
49
|
joined and aligned under those rules.
|
|
48
50
|
|
|
49
|
-
**Status:** version `0.1.
|
|
51
|
+
**Status:** version `0.1.2`; the API may still
|
|
50
52
|
change before 1.0. What changed in each release is in
|
|
51
53
|
[CHANGELOG.md](CHANGELOG.md).
|
|
52
54
|
|
|
@@ -14,11 +14,13 @@ It is deliberately small. What it offers that other array libraries do not:
|
|
|
14
14
|
2. **Reproducible reductions.** Float32 sums and means are correctly
|
|
15
15
|
rounded: the float32 nearest the exact result, the same in any memory
|
|
16
16
|
layout. Grouped reductions are bit-identical to reducing each group on
|
|
17
|
-
its own
|
|
17
|
+
its own, and data held in chunks (Dask) gets the same bits whatever the
|
|
18
|
+
chunking: each chunk's estimate combines in any order, and exact sums
|
|
19
|
+
settle the few results an error bound cannot.
|
|
18
20
|
3. **Collections.** Arrays sharing axes, with one label array per axis,
|
|
19
21
|
joined and aligned under those rules.
|
|
20
22
|
|
|
21
|
-
**Status:** version `0.1.
|
|
23
|
+
**Status:** version `0.1.2`; the API may still
|
|
22
24
|
change before 1.0. What changed in each release is in
|
|
23
25
|
[CHANGELOG.md](CHANGELOG.md).
|
|
24
26
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -19,6 +19,15 @@ Two ways, chosen by how many values each result adds:
|
|
|
19
19
|
a bound cannot decide), so cancellation among those few values is rounded
|
|
20
20
|
in float64 as it goes: ``[2**53, 1, -2**53]`` sums to 0 here, not 1.
|
|
21
21
|
|
|
22
|
+
Data held in chunks (Dask) gets the same bits as data in memory, whatever the
|
|
23
|
+
chunking: ``chunk_estimate`` gives each chunk's float64 totals, sums of
|
|
24
|
+
magnitudes, counts, and the term count of its error bound, which combine
|
|
25
|
+
across chunks in any order; ``settle`` certifies the combined estimate;
|
|
26
|
+
``chunk_exact`` gives each chunk's exact sums, as integer buckets per
|
|
27
|
+
exponent, for the groups the bound could not settle, which add across chunks;
|
|
28
|
+
and ``from_chunks`` drives the two passes. A correctly rounded result is
|
|
29
|
+
unique, so these give the bits ``reduce`` gives.
|
|
30
|
+
|
|
22
31
|
Missing values: without ``na_rm`` a NaN makes its result NaN; with it, NaN
|
|
23
32
|
is skipped and a mean divides by the values present. +inf and -inf together
|
|
24
33
|
give NaN. A sum of no values is +0.0 and a mean NaN; a zero sum is +0.0, as
|
|
@@ -79,10 +88,8 @@ def reduce(values: np.ndarray, op: str, axes: Sequence[int], *, na_rm: bool = Fa
|
|
|
79
88
|
result = np.where(present >= min_count, result, np.float32(np.nan))
|
|
80
89
|
return result.reshape(kept_shape)
|
|
81
90
|
return _in_order(values, axes, kept_shape, op, na_rm, min_count)
|
|
82
|
-
table =
|
|
91
|
+
table, rows, others = _table(values, axes, kept, codes)
|
|
83
92
|
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
93
|
if sizes is not None and others != 1:
|
|
87
94
|
sizes = np.asarray(sizes) * others
|
|
88
95
|
result = _settled(table, rows, groups, sizes, op, na_rm, min_count)
|
|
@@ -90,6 +97,18 @@ def reduce(values: np.ndarray, op: str, axes: Sequence[int], *, na_rm: bool = Fa
|
|
|
90
97
|
return _settled(table, None, 1, None, op, na_rm, min_count).reshape(kept_shape)
|
|
91
98
|
|
|
92
99
|
|
|
100
|
+
def _table(values, axes, kept, codes):
|
|
101
|
+
"""*values* as (positions along *axes*, positions along *kept*), each position's group code, and positions per code."""
|
|
102
|
+
positions = math.prod(values.shape[a] for a in axes)
|
|
103
|
+
width = math.prod(values.shape[a] for a in kept)
|
|
104
|
+
table = np.transpose(values, axes + kept).reshape(positions, width)
|
|
105
|
+
if codes is None:
|
|
106
|
+
return table, None, 1
|
|
107
|
+
others = math.prod(values.shape[a] for a in axes[1:])
|
|
108
|
+
rows = np.asarray(codes) if others == 1 else np.repeat(np.asarray(codes), others)
|
|
109
|
+
return table, rows, others
|
|
110
|
+
|
|
111
|
+
|
|
93
112
|
def _in_order(values, axes, kept_shape, op, na_rm, min_count) -> np.ndarray:
|
|
94
113
|
"""Few values per result: float64, added first to last along *axes*, rounded once.
|
|
95
114
|
|
|
@@ -138,7 +157,7 @@ def _settled(table, rows, groups, sizes, op, na_rm, min_count) -> np.ndarray:
|
|
|
138
157
|
return result
|
|
139
158
|
|
|
140
159
|
|
|
141
|
-
def _estimate(table, rows, groups, sizes, na_rm):
|
|
160
|
+
def _estimate(table, rows, groups, sizes, na_rm, compiled=True):
|
|
142
161
|
"""Float64 sums, sums of magnitudes, values present, values, and terms for the bound, per group and column.
|
|
143
162
|
|
|
144
163
|
The NumPy path adds chunks of positions (one ``np.bincount`` over
|
|
@@ -164,7 +183,7 @@ def _estimate(table, rows, groups, sizes, na_rm):
|
|
|
164
183
|
if sizes is None:
|
|
165
184
|
sizes = np.bincount(rows, minlength=groups)
|
|
166
185
|
sizes = np.asarray(sizes)
|
|
167
|
-
kernel = _compiled("grouped_estimate") if COMPILED and table.size >= COMPILED_FROM else None
|
|
186
|
+
kernel = _compiled("grouped_estimate") if compiled and COMPILED and table.size >= COMPILED_FROM else None
|
|
168
187
|
if kernel is not None:
|
|
169
188
|
total, magnitude, present = kernel(table, np.ascontiguousarray(rows, dtype=np.intp), groups, na_rm)
|
|
170
189
|
values = np.repeat(sizes[:, None], width, axis=1)
|
|
@@ -214,7 +233,14 @@ def _certify(total, magnitude, terms, counts, op, compensated):
|
|
|
214
233
|
|
|
215
234
|
|
|
216
235
|
def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
|
|
217
|
-
"""Correctly rounded results for groups *chosen*, from exact sums of their values.
|
|
236
|
+
"""Correctly rounded results for groups *chosen*, from exact sums of their values.
|
|
237
|
+
|
|
238
|
+
Most such groups are small and hold values of nearby magnitudes (their
|
|
239
|
+
exact mean lies on a float32 tie, where the bound cannot decide): their
|
|
240
|
+
exact sums fit in 64-bit integers and are rounded with NumPy
|
|
241
|
+
(``_rounded``). Groups spanning a wider range of exponents are summed and
|
|
242
|
+
rounded in Python integers and fractions.
|
|
243
|
+
"""
|
|
218
244
|
if rows is None:
|
|
219
245
|
part, codes, count = values, np.zeros(len(values), dtype=np.intp), 1
|
|
220
246
|
else:
|
|
@@ -223,27 +249,41 @@ def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
|
|
|
223
249
|
codes = remap[rows]
|
|
224
250
|
keep = codes >= 0
|
|
225
251
|
part, codes, count = values[keep], codes[keep], len(chosen)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
252
|
+
buckets, low, present, nan, plus, minus = _exact_sums(part, codes, count)
|
|
253
|
+
return _exact_finish(buckets, low, present, nan, plus, minus, op, na_rm)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _exact_finish(buckets, low, present, nan, plus, minus, op, na_rm) -> np.ndarray:
|
|
257
|
+
"""Correctly rounded results from exact sums: *buckets* ``(count, width)`` per group and exponent from *low*."""
|
|
258
|
+
count = len(buckets)
|
|
259
|
+
out = np.full(count, np.nan, dtype=np.float32)
|
|
260
|
+
undefined = (nan & (not na_rm)) | (plus & minus)
|
|
261
|
+
infinite = (plus | minus) & ~undefined
|
|
262
|
+
out[infinite] = np.where(plus[infinite], np.float32(np.inf), np.float32(-np.inf))
|
|
263
|
+
regular = ~(undefined | infinite)
|
|
235
264
|
if op == "mean":
|
|
236
|
-
|
|
265
|
+
regular &= present > 0 # a mean of no values stays NaN
|
|
266
|
+
divisor = present if op == "mean" else np.ones(count, dtype=np.int64)
|
|
267
|
+
whole, first, fits = _whole(buckets)
|
|
268
|
+
fast = regular & fits & (divisor < 2**35) # so the integer quotient below stays within int64
|
|
269
|
+
if fast.any():
|
|
270
|
+
out[fast] = _rounded(whole[fast], low + first[fast] - 150, divisor[fast])
|
|
271
|
+
for k in np.flatnonzero(regular & ~fits):
|
|
272
|
+
row = buckets[k]
|
|
273
|
+
total = sum(int(row[j]) << int(j) for j in np.flatnonzero(row)) * Fraction(2) ** (low - 150)
|
|
274
|
+
out[k] = _round32(total / int(divisor[k]))
|
|
237
275
|
return out
|
|
238
276
|
|
|
239
277
|
|
|
240
|
-
def _exact_sums(values, codes, count
|
|
241
|
-
"""Exact sums as
|
|
278
|
+
def _exact_sums(values, codes, count):
|
|
279
|
+
"""Exact sums as integer buckets per group and exponent, the lowest exponent, values present, and specials.
|
|
242
280
|
|
|
243
281
|
A float32 is M * 2**(e - 150) with M a signed integer below 2**24 and e
|
|
244
282
|
from 1 to 254. Integers M with the same e add exactly in float64 for
|
|
245
283
|
fewer than 2**29 values, so they are summed per group and exponent with
|
|
246
|
-
``np.bincount
|
|
284
|
+
``np.bincount``: group k's exact sum is the sum over j of
|
|
285
|
+
``buckets[k, j] * 2**(low + j - 150)``. Also, per group, the values
|
|
286
|
+
present (not NaN), and whether a NaN, +inf, or -inf is among them.
|
|
247
287
|
"""
|
|
248
288
|
bits = values.view(np.uint32)
|
|
249
289
|
exponent = ((bits >> 23) & 0xFF).astype(np.intp)
|
|
@@ -257,30 +297,77 @@ def _exact_sums(values, codes, count, na_rm):
|
|
|
257
297
|
has_nan = np.bincount(codes, weights=nan, minlength=count) > 0
|
|
258
298
|
plus = np.bincount(codes, weights=values == np.inf, minlength=count) > 0
|
|
259
299
|
minus = np.bincount(codes, weights=values == -np.inf, minlength=count) > 0
|
|
260
|
-
|
|
300
|
+
low, width = 1, 1
|
|
261
301
|
if finite.any():
|
|
262
302
|
low, high = int(exponent[finite].min()), int(exponent[finite].max())
|
|
263
303
|
width = high - low + 1
|
|
304
|
+
buckets = np.zeros(count * width, dtype=np.int64)
|
|
305
|
+
if finite.any():
|
|
264
306
|
key = codes[finite] * width + (exponent[finite] - low)
|
|
265
307
|
weights = mantissa[finite].astype(np.float64)
|
|
266
|
-
buckets = np.zeros(count * width, dtype=np.int64)
|
|
267
308
|
chunk = 1 << 28 # each bucket sum stays below 2**52, so float64 holds it exactly
|
|
268
309
|
for start in range(0, len(key), chunk):
|
|
269
310
|
buckets += np.bincount(key[start:start + chunk], weights=weights[start:start + chunk],
|
|
270
311
|
minlength=count * width).astype(np.int64)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
312
|
+
return buckets.reshape(count, width), low, present, has_nan, plus, minus
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _whole(buckets: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
316
|
+
"""Each group's exact sum as one integer, in units of its lowest nonzero bucket; that bucket; where the integer is below 2**52.
|
|
317
|
+
|
|
318
|
+
Where it is, the shifted buckets and their sum stay within int64 and the
|
|
319
|
+
sum is exact in float64 too; other groups need Python integers. Measured
|
|
320
|
+
from each group's own lowest bucket, a group of any magnitude fits when
|
|
321
|
+
its values span few exponents.
|
|
322
|
+
"""
|
|
323
|
+
count, width = buckets.shape
|
|
324
|
+
nonzero = buckets != 0
|
|
325
|
+
first = np.where(nonzero.any(axis=1), nonzero.argmax(axis=1), 0)
|
|
326
|
+
place = first[:, None] + np.arange(width)
|
|
327
|
+
aligned = np.where(place < width, np.take_along_axis(buckets, np.minimum(place, width - 1), axis=1), 0)
|
|
328
|
+
shifts = np.arange(width)
|
|
329
|
+
bound = (np.abs(aligned).astype(np.float64) * np.ldexp(1.0, shifts)).sum(axis=1) # exact terms; rounding is small
|
|
330
|
+
fits = bound < 2.0**52
|
|
331
|
+
whole = np.zeros(count, dtype=np.int64)
|
|
332
|
+
if fits.any():
|
|
333
|
+
# Shifts of zero buckets beyond 62 bits are clamped; a nonzero bucket that far up does not fit.
|
|
334
|
+
whole[fits] = np.left_shift(aligned[fits], np.minimum(shifts, 62)).sum(axis=1)
|
|
335
|
+
return whole, first, fits
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _rounded(whole: np.ndarray, scale, divisor: np.ndarray) -> np.ndarray:
|
|
339
|
+
"""``whole * 2**scale / divisor`` correctly rounded to float32, ties to even, for |whole| below 2**52.
|
|
340
|
+
|
|
341
|
+
The quotient is taken in integers to 27 or 28 significant bits, with its
|
|
342
|
+
last bit set when anything was left over ("round to odd"); with at least
|
|
343
|
+
two bits more than float32 keeps, the one rounding from float64 to
|
|
344
|
+
float32 then rounds as the exact quotient would, subnormals included.
|
|
345
|
+
"""
|
|
346
|
+
sign = np.where(whole < 0, -1.0, 1.0)
|
|
347
|
+
size = np.abs(whole)
|
|
348
|
+
divisor = divisor.astype(np.int64)
|
|
349
|
+
zero = size == 0
|
|
350
|
+
size = np.where(zero, 1, size) # placeholders; zero sums are +0.0, as NumPy gives them
|
|
351
|
+
size_bits = np.frexp(size.astype(np.float64))[1].astype(np.int64) # bit lengths, exact below 2**53
|
|
352
|
+
divisor_bits = np.frexp(divisor.astype(np.float64))[1].astype(np.int64)
|
|
353
|
+
up = 27 + divisor_bits - size_bits # shift so the quotient has 27 or 28 bits
|
|
354
|
+
quotient = np.empty(len(size), dtype=np.int64)
|
|
355
|
+
rest = np.empty(len(size), dtype=bool)
|
|
356
|
+
left = up >= 0
|
|
357
|
+
if left.any(): # size << up has 27 or 28 more bits than the divisor: below 2**63 for divisors below 2**35
|
|
358
|
+
numerator = np.left_shift(size[left], up[left])
|
|
359
|
+
quotient[left], remainder = np.divmod(numerator, divisor[left])
|
|
360
|
+
rest[left] = remainder != 0
|
|
361
|
+
right = ~left
|
|
362
|
+
if right.any():
|
|
363
|
+
whole_part, remainder = np.divmod(size[right], divisor[right])
|
|
364
|
+
down = -up[right]
|
|
365
|
+
quotient[right] = np.right_shift(whole_part, down)
|
|
366
|
+
rest[right] = (remainder != 0) | ((whole_part & ((np.int64(1) << down) - 1)) != 0)
|
|
367
|
+
quotient |= rest.astype(np.int64)
|
|
368
|
+
result = np.ldexp(sign * quotient.astype(np.float64), scale - up).astype(np.float32)
|
|
369
|
+
result[zero] = np.float32(0.0)
|
|
370
|
+
return result
|
|
284
371
|
|
|
285
372
|
|
|
286
373
|
def _round32(value: Fraction) -> np.float32:
|
|
@@ -300,6 +387,151 @@ def _round32(value: Fraction) -> np.float32:
|
|
|
300
387
|
return np.float32(best)
|
|
301
388
|
|
|
302
389
|
|
|
390
|
+
# ── Chunked data: pieces that combine in any order ──────────────────────────
|
|
391
|
+
|
|
392
|
+
PIECES = 7
|
|
393
|
+
TOTAL, MAGNITUDE, PRESENT, VALUES, PLUS, MINUS, TERMS = range(PIECES)
|
|
394
|
+
BUCKETS = 254 + 4 # one per float32 exponent, then values present, NaN, +inf, and -inf
|
|
395
|
+
EXACT_PAIRS = 1 << 14 # (group, column) pairs per exact pass: 33 MB of buckets per chunk
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def chunk_estimate(values: np.ndarray, axes: Sequence[int], *, na_rm: bool = False,
|
|
399
|
+
codes: np.ndarray | None = None, groups: int = 0) -> np.ndarray:
|
|
400
|
+
"""One chunk's estimate of its float32 sums over *axes*: ``(PIECES, groups, *kept)`` in float64.
|
|
401
|
+
|
|
402
|
+
The pieces, per group (one without *codes*) and kept position: the
|
|
403
|
+
float64 total, the sum of magnitudes, the values present (not NaN), the
|
|
404
|
+
values, the +inf and the -inf among them, and the term count of the
|
|
405
|
+
total's error bound. Chunks combine in any order: every piece adds,
|
|
406
|
+
except ``TERMS``, whose maximum is taken (``settle`` adds the number of
|
|
407
|
+
chunks to it: Higham's lemma 3.3, where a float64 sum of totals each
|
|
408
|
+
within gamma(n_i) of exact is within gamma(max n_i + k) of exact). The
|
|
409
|
+
integers stay exact in float64 below 2**53 values. The NumPy path
|
|
410
|
+
estimates every chunk: chunks run in parallel already, and the compiled
|
|
411
|
+
kernel's own threads cannot be entered from several at once.
|
|
412
|
+
"""
|
|
413
|
+
values = np.asarray(values)
|
|
414
|
+
if values.dtype != np.float32:
|
|
415
|
+
raise TypeError(f"float32 values expected, got {values.dtype}")
|
|
416
|
+
axes = tuple(int(a) for a in axes)
|
|
417
|
+
kept = tuple(a for a in range(values.ndim) if a not in axes)
|
|
418
|
+
kept_shape = tuple(values.shape[a] for a in kept)
|
|
419
|
+
count = groups if codes is not None else 1
|
|
420
|
+
with np.errstate(all="ignore"):
|
|
421
|
+
table, rows, _ = _table(values, axes, kept, codes)
|
|
422
|
+
total, magnitude, present, counted, terms, _ = _estimate(table, rows, count, None, na_rm, compiled=False)
|
|
423
|
+
width = table.shape[1]
|
|
424
|
+
plus = np.zeros((count, width))
|
|
425
|
+
minus = np.zeros((count, width))
|
|
426
|
+
if np.isinf(table).any():
|
|
427
|
+
key = ((np.zeros(len(table), dtype=np.intp) if rows is None else rows)[:, None] * width
|
|
428
|
+
+ np.arange(width)).ravel()
|
|
429
|
+
plus = np.bincount(key, weights=(table == np.inf).ravel(), minlength=count * width).reshape(count, width)
|
|
430
|
+
minus = np.bincount(key, weights=(table == -np.inf).ravel(), minlength=count * width).reshape(count, width)
|
|
431
|
+
pieces = np.stack([total, magnitude, present, counted, plus, minus, terms]).astype(np.float64)
|
|
432
|
+
return pieces.reshape((PIECES, count, *kept_shape))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def settle(pieces: np.ndarray, chunks: int, op: str, na_rm: bool) -> tuple[np.ndarray, np.ndarray]:
|
|
436
|
+
"""Results the combined *pieces* of *chunks* chunks settle, and where they do not: ``(result, unsure)``.
|
|
437
|
+
|
|
438
|
+
Where the error bound proves the rounding, the result is the correctly
|
|
439
|
+
rounded one; NaN, +inf and -inf follow the missing-value rules from the
|
|
440
|
+
counts; a mean of no values is NaN. The rest, ``unsure``, need exact sums.
|
|
441
|
+
"""
|
|
442
|
+
with np.errstate(all="ignore"):
|
|
443
|
+
total, magnitude, present, counted, plus, minus, terms = pieces
|
|
444
|
+
counts = present if na_rm else counted
|
|
445
|
+
result, sure = _certify(total, magnitude, terms + chunks, counts, op, False)
|
|
446
|
+
undefined = ((counted - present > 0) & (not na_rm)) | ((plus > 0) & (minus > 0))
|
|
447
|
+
infinite = ((plus > 0) | (minus > 0)) & ~undefined
|
|
448
|
+
result[undefined] = np.nan
|
|
449
|
+
result[infinite] = np.where(plus[infinite] > 0, np.float32(np.inf), np.float32(-np.inf))
|
|
450
|
+
sure |= undefined | infinite
|
|
451
|
+
if op == "mean":
|
|
452
|
+
empty = (counts == 0) & ~undefined
|
|
453
|
+
result[empty] = np.nan
|
|
454
|
+
sure |= empty
|
|
455
|
+
return result, ~sure
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def chunk_exact(values: np.ndarray, axes: Sequence[int], chosen: Sequence[int], *,
|
|
459
|
+
codes: np.ndarray | None = None, groups: int = 0) -> np.ndarray:
|
|
460
|
+
"""One chunk's exact sums over *axes* for the groups *chosen*: ``(len(chosen), *kept, BUCKETS)`` in int64.
|
|
461
|
+
|
|
462
|
+
Per chosen group and kept position: the exact sum as integer buckets, one
|
|
463
|
+
per float32 exponent (``_exact_sums``), then the values present and the
|
|
464
|
+
NaN, +inf and -inf among them. Chunks add. Without *codes*, *chosen* is
|
|
465
|
+
``[0]``. A bucket stays within int64 below 2**39 values of one exponent.
|
|
466
|
+
"""
|
|
467
|
+
values = np.asarray(values)
|
|
468
|
+
if values.dtype != np.float32:
|
|
469
|
+
raise TypeError(f"float32 values expected, got {values.dtype}")
|
|
470
|
+
axes = tuple(int(a) for a in axes)
|
|
471
|
+
kept = tuple(a for a in range(values.ndim) if a not in axes)
|
|
472
|
+
kept_shape = tuple(values.shape[a] for a in kept)
|
|
473
|
+
chosen = np.asarray(chosen, dtype=np.intp)
|
|
474
|
+
count = len(chosen)
|
|
475
|
+
with np.errstate(all="ignore"):
|
|
476
|
+
table, rows, _ = _table(values, axes, kept, codes)
|
|
477
|
+
positions, width = table.shape
|
|
478
|
+
if rows is None:
|
|
479
|
+
part, pair_codes = table, np.zeros(positions, dtype=np.intp)
|
|
480
|
+
else:
|
|
481
|
+
remap = np.full(max(groups, 1), -1, dtype=np.intp)
|
|
482
|
+
remap[chosen] = np.arange(count)
|
|
483
|
+
pair_codes = remap[rows]
|
|
484
|
+
keep = pair_codes >= 0
|
|
485
|
+
part, pair_codes = table[keep], pair_codes[keep]
|
|
486
|
+
pairs = (pair_codes[:, None] * width + np.arange(width)).ravel()
|
|
487
|
+
buckets, low, present, nan, plus, minus = _exact_sums(np.ascontiguousarray(part).ravel(), pairs, count * width)
|
|
488
|
+
out = np.zeros((count * width, BUCKETS), dtype=np.int64)
|
|
489
|
+
out[:, low - 1:low - 1 + buckets.shape[1]] = buckets
|
|
490
|
+
out[:, 254] = present
|
|
491
|
+
out[:, 255] = nan
|
|
492
|
+
out[:, 256] = plus
|
|
493
|
+
out[:, 257] = minus
|
|
494
|
+
return out.reshape((count, *kept_shape, BUCKETS))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def exact_results(buckets: np.ndarray, op: str, na_rm: bool) -> np.ndarray:
|
|
498
|
+
"""Correctly rounded results from exact sums added across chunks: ``(..., BUCKETS)`` in, float32 out."""
|
|
499
|
+
buckets = np.asarray(buckets)
|
|
500
|
+
shape = buckets.shape[:-1]
|
|
501
|
+
flat = buckets.reshape(-1, BUCKETS)
|
|
502
|
+
with np.errstate(all="ignore"):
|
|
503
|
+
out = _exact_finish(flat[:, :254], 1, flat[:, 254], flat[:, 255] > 0, flat[:, 256] > 0, flat[:, 257] > 0,
|
|
504
|
+
op, na_rm)
|
|
505
|
+
return out.reshape(shape)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def from_chunks(pieces: np.ndarray, chunks: int, op: str, exact, *, na_rm: bool = False,
|
|
509
|
+
min_count: int | None = None) -> np.ndarray:
|
|
510
|
+
"""*op* from the combined *pieces* of *chunks* chunks, with exact sums where the bound cannot settle a result.
|
|
511
|
+
|
|
512
|
+
*exact(chosen)* returns the chunks' exact sums for the groups *chosen*
|
|
513
|
+
(``chunk_exact``, added across chunks); it is called once per batch of
|
|
514
|
+
``EXACT_PAIRS`` (group, kept position) pairs, and not at all where the
|
|
515
|
+
bound settles everything. The result is ``(groups, *kept)`` in float32,
|
|
516
|
+
the same bits as ``reduce`` gives in memory.
|
|
517
|
+
"""
|
|
518
|
+
pieces = np.asarray(pieces, dtype=np.float64)
|
|
519
|
+
result, unsure = settle(pieces, chunks, op, na_rm)
|
|
520
|
+
count = len(result)
|
|
521
|
+
width = result.size // count if count else 0
|
|
522
|
+
rows = np.flatnonzero(unsure.reshape(count, width).any(axis=1)) if count else np.zeros(0, dtype=np.intp)
|
|
523
|
+
batch = max(1, EXACT_PAIRS // max(width, 1))
|
|
524
|
+
for start in range(0, len(rows), batch):
|
|
525
|
+
chosen = rows[start:start + batch]
|
|
526
|
+
done = exact_results(exact(chosen), op, na_rm)
|
|
527
|
+
picked, mask = result[chosen], unsure[chosen]
|
|
528
|
+
picked[mask] = done[mask]
|
|
529
|
+
result[chosen] = picked
|
|
530
|
+
if op == "sum" and min_count is not None:
|
|
531
|
+
result = np.where(pieces[PRESENT] >= min_count, result, np.float32(np.nan))
|
|
532
|
+
return result
|
|
533
|
+
|
|
534
|
+
|
|
303
535
|
# ── The optional compiled kernel ────────────────────────────────────────────
|
|
304
536
|
|
|
305
537
|
_KERNELS: dict = {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labelarray
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Labelled N-D arrays in pure NumPy, with exact label semantics and reproducible reductions
|
|
5
5
|
Author: Rigoberto Leyva Salmeron
|
|
6
6
|
License-Expression: MIT
|
|
@@ -42,11 +42,13 @@ It is deliberately small. What it offers that other array libraries do not:
|
|
|
42
42
|
2. **Reproducible reductions.** Float32 sums and means are correctly
|
|
43
43
|
rounded: the float32 nearest the exact result, the same in any memory
|
|
44
44
|
layout. Grouped reductions are bit-identical to reducing each group on
|
|
45
|
-
its own
|
|
45
|
+
its own, and data held in chunks (Dask) gets the same bits whatever the
|
|
46
|
+
chunking: each chunk's estimate combines in any order, and exact sums
|
|
47
|
+
settle the few results an error bound cannot.
|
|
46
48
|
3. **Collections.** Arrays sharing axes, with one label array per axis,
|
|
47
49
|
joined and aligned under those rules.
|
|
48
50
|
|
|
49
|
-
**Status:** version `0.1.
|
|
51
|
+
**Status:** version `0.1.2`; the API may still
|
|
50
52
|
change before 1.0. What changed in each release is in
|
|
51
53
|
[CHANGELOG.md](CHANGELOG.md).
|
|
52
54
|
|
|
@@ -61,9 +61,14 @@ def expected(values: np.ndarray, op: str, na_rm: bool) -> np.float32:
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def same_bits(a, b) -> bool:
|
|
64
|
-
|
|
64
|
+
return not differing(a, b).size
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def differing(a, b) -> np.ndarray:
|
|
68
|
+
"""Where *a* and *b* differ in their bits, NaN counting as equal to NaN."""
|
|
69
|
+
a, b = np.atleast_1d(np.asarray(a, F32)), np.atleast_1d(np.asarray(b, F32))
|
|
65
70
|
both_nan = np.isnan(a) & np.isnan(b)
|
|
66
|
-
return
|
|
71
|
+
return np.flatnonzero(~(both_nan | (a.view(np.uint32) == b.view(np.uint32))))
|
|
67
72
|
|
|
68
73
|
|
|
69
74
|
def corpus():
|
|
@@ -233,3 +238,200 @@ def test_compiled_kernels_run_only_from_a_size_where_they_pay(monkeypatch):
|
|
|
233
238
|
sums.reduce(many, "sum", (0,))
|
|
234
239
|
sums.reduce(many[0, :sums.COMPILED_FROM], "mean", (0,), codes=rng.integers(0, 5, sums.COMPILED_FROM), groups=5)
|
|
235
240
|
assert calls == ["in_order", "grouped_estimate"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ── Many tiny groups, where exact means and sums often lie on float32 ties ──
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def tiny_groups(seed, count=3000, spread=(0.0, 2.4)):
|
|
247
|
+
"""Groups of 1 to 4 float32 values, many of whose exact sums and means are float32 ties."""
|
|
248
|
+
rng = np.random.default_rng(seed)
|
|
249
|
+
sizes = rng.integers(1, 5, count)
|
|
250
|
+
keys = np.repeat(np.arange(count), sizes)
|
|
251
|
+
values = rng.uniform(0, 255, len(keys)) * 10.0 ** rng.uniform(*spread, len(keys)) * rng.choice([-1, 1], len(keys))
|
|
252
|
+
values = values.astype(F32)
|
|
253
|
+
order = rng.permutation(len(values))
|
|
254
|
+
return values[order], keys[order]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _reduced(values, keys, count, op, na_rm=False, min_count=None):
|
|
258
|
+
return sums.reduce(values[:, None], op, (0,), codes=keys, groups=count, na_rm=na_rm, min_count=min_count)[:, 0]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@pytest.mark.parametrize("seed", range(6))
|
|
262
|
+
@pytest.mark.parametrize("op", ["sum", "mean"])
|
|
263
|
+
def test_tiny_groups_on_ties_are_correctly_rounded(seed, op):
|
|
264
|
+
values, keys = tiny_groups(seed)
|
|
265
|
+
count = int(keys.max()) + 1
|
|
266
|
+
got = _reduced(values, keys, count, op)
|
|
267
|
+
groups = [values[keys == k] for k in range(count)]
|
|
268
|
+
want = np.array([expected(g, op, False) for g in groups], F32)
|
|
269
|
+
wrong = differing(got, want)
|
|
270
|
+
assert not wrong.size, [(k, groups[k], got[k], want[k]) for k in wrong[:5]]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.mark.parametrize("na_rm", [False, True])
|
|
274
|
+
def test_tiny_groups_with_subnormals_zeros_and_missing_values(na_rm):
|
|
275
|
+
rng = np.random.default_rng(7)
|
|
276
|
+
groups = []
|
|
277
|
+
for _ in range(2000):
|
|
278
|
+
size = int(rng.integers(1, 5))
|
|
279
|
+
kind = rng.integers(0, 6)
|
|
280
|
+
if kind == 0:
|
|
281
|
+
group = rng.integers(-2**23, 2**23, size) * 2.0**-149 # subnormals, and ties between them
|
|
282
|
+
elif kind == 1:
|
|
283
|
+
group = rng.choice([0.0, -0.0], size)
|
|
284
|
+
elif kind == 2:
|
|
285
|
+
group = np.where(rng.random(size) < 0.4, np.nan, rng.uniform(0, 255, size))
|
|
286
|
+
elif kind == 3:
|
|
287
|
+
group = rng.uniform(1, 2, size) * rng.choice([1, 2**-126, 2**100], size) # narrow and wide spreads
|
|
288
|
+
elif kind == 4:
|
|
289
|
+
group = rng.choice([np.inf, -np.inf, 1.0], size)
|
|
290
|
+
else:
|
|
291
|
+
group = rng.uniform(0, 255, size)
|
|
292
|
+
groups.append(np.asarray(group, F32))
|
|
293
|
+
values = np.concatenate(groups)
|
|
294
|
+
keys = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
|
|
295
|
+
for op in ("sum", "mean"):
|
|
296
|
+
got = _reduced(values, keys, len(groups), op, na_rm=na_rm)
|
|
297
|
+
want = np.array([expected(g, op, na_rm) for g in groups], F32)
|
|
298
|
+
wrong = differing(got, want)
|
|
299
|
+
assert not wrong.size, [(op, k, groups[k], got[k], want[k]) for k in wrong[:5]]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_narrow_groups_are_decided_without_fractions(monkeypatch):
|
|
303
|
+
values, keys = tiny_groups(11, count=5000)
|
|
304
|
+
count = int(keys.max()) + 1
|
|
305
|
+
monkeypatch.setattr(sums, "_round32", lambda value: pytest.fail("a narrow group was decided with fractions"))
|
|
306
|
+
for op in ("sum", "mean"):
|
|
307
|
+
_reduced(values, keys, count, op)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_groups_spanning_wide_exponents_still_add_exactly(monkeypatch):
|
|
311
|
+
big = 2.0**100
|
|
312
|
+
groups = [[big, 1.0, -big], [1e30, 3.0, -1e30, 1.0], [2.0**-140, 2.0**120, -(2.0**120)],
|
|
313
|
+
[big, 1.0, -big, 2.0**-100], [1e30, 3.0, -1e30, 1.0, 2.0**-60]] # what is left after cancelling spans wide exponents
|
|
314
|
+
values = np.concatenate([np.asarray(g, F32) for g in groups])
|
|
315
|
+
keys = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
|
|
316
|
+
calls = []
|
|
317
|
+
original = sums._round32
|
|
318
|
+
monkeypatch.setattr(sums, "_round32", lambda value: calls.append(value) or original(value))
|
|
319
|
+
for op in ("sum", "mean"):
|
|
320
|
+
got = _reduced(values, keys, len(groups), op)
|
|
321
|
+
assert same_bits(got, np.array([expected(np.asarray(g, F32), op, False) for g in groups], F32))
|
|
322
|
+
assert calls # these need the exact path in Python integers
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ── Chunked data: the same bits whatever the chunking ───────────────────────
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _chunked(values, keys, count, op, bounds, na_rm=False, min_count=None, calls=None):
|
|
329
|
+
"""``sums.from_chunks`` over *values* cut at *bounds*, the chunks combined in a shuffled order."""
|
|
330
|
+
parts = [slice(a, b) for a, b in zip(bounds[:-1], bounds[1:])]
|
|
331
|
+
order = np.random.default_rng(len(bounds)).permutation(len(parts))
|
|
332
|
+
pieces = None
|
|
333
|
+
for i in order:
|
|
334
|
+
part = parts[i]
|
|
335
|
+
codes = None if keys is None else keys[part]
|
|
336
|
+
piece = sums.chunk_estimate(values[part], (0,), na_rm=na_rm, codes=codes, groups=count)
|
|
337
|
+
if pieces is None:
|
|
338
|
+
pieces = piece
|
|
339
|
+
else:
|
|
340
|
+
pieces[:sums.TERMS] += piece[:sums.TERMS]
|
|
341
|
+
pieces[sums.TERMS] = np.maximum(pieces[sums.TERMS], piece[sums.TERMS])
|
|
342
|
+
|
|
343
|
+
def exact(chosen):
|
|
344
|
+
if calls is not None:
|
|
345
|
+
calls.append(list(chosen))
|
|
346
|
+
codes = (lambda part: None) if keys is None else (lambda part: keys[part])
|
|
347
|
+
return sum(sums.chunk_exact(values[parts[i]], (0,), chosen, codes=codes(parts[i]), groups=count)
|
|
348
|
+
for i in order)
|
|
349
|
+
|
|
350
|
+
return sums.from_chunks(pieces, len(parts), op, exact, na_rm=na_rm, min_count=min_count)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _bounds(n, chunks, seed=0):
|
|
354
|
+
"""*chunks* uneven cuts of ``range(n)``, some possibly empty."""
|
|
355
|
+
cuts = np.sort(np.random.default_rng(seed).integers(0, n + 1, chunks - 1))
|
|
356
|
+
return [0, *cuts.tolist(), n]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@pytest.mark.parametrize("chunks", [1, 3, 17, 400])
|
|
360
|
+
@pytest.mark.parametrize("na_rm", [False, True])
|
|
361
|
+
def test_chunked_grouped_sums_give_the_in_memory_bits_for_any_chunking(chunks, na_rm):
|
|
362
|
+
values, keys, groups = corpus()
|
|
363
|
+
count = len(groups)
|
|
364
|
+
for op in ("sum", "mean"):
|
|
365
|
+
want = _reduced(values, keys, count, op, na_rm=na_rm)
|
|
366
|
+
got = _chunked(values[:, None], keys, count, op, _bounds(len(values), chunks, seed=chunks), na_rm=na_rm)[:, 0]
|
|
367
|
+
wrong = differing(got, want)
|
|
368
|
+
assert not wrong.size, [(op, chunks, k, groups[k], got[k], want[k]) for k in wrong[:5]]
|
|
369
|
+
assert same_bits(want, np.array([expected(g, op, na_rm) for g in groups], F32))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@pytest.mark.parametrize("seed", range(3))
|
|
373
|
+
def test_chunk_boundaries_inside_tiny_groups_keep_the_ties_right(seed):
|
|
374
|
+
values, keys = tiny_groups(seed)
|
|
375
|
+
count = int(keys.max()) + 1
|
|
376
|
+
bounds = _bounds(len(values), 9, seed=seed)
|
|
377
|
+
for op in ("sum", "mean"):
|
|
378
|
+
calls = []
|
|
379
|
+
got = _chunked(values[:, None], keys, count, op, bounds, calls=calls)[:, 0]
|
|
380
|
+
want = np.array([expected(values[keys == k], op, False) for k in range(count)], F32)
|
|
381
|
+
assert same_bits(got, want)
|
|
382
|
+
assert calls and all(len(c) <= sums.EXACT_PAIRS for c in calls) # ties need the exact pass
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_chunked_ungrouped_sums_keep_the_other_axes():
|
|
386
|
+
rng = np.random.default_rng(5)
|
|
387
|
+
values = (rng.normal(0, 1e4, (5000, 3)) * rng.choice([1, 2.0**-30, 2.0**30], (5000, 3))).astype(F32)
|
|
388
|
+
values[::7, 1] = values[1::7, 1] + np.spacing(values[1::7, 1]) / 2 # pairs on float32 ties
|
|
389
|
+
for op in ("sum", "mean"):
|
|
390
|
+
want = sums.reduce(values, op, (0,))
|
|
391
|
+
for chunks in (1, 4, 50):
|
|
392
|
+
got = _chunked(values, None, 1, op, _bounds(len(values), chunks, seed=chunks))[0]
|
|
393
|
+
assert same_bits(got, want), (op, chunks)
|
|
394
|
+
for j in range(3):
|
|
395
|
+
assert same_bits(want[j], expected(values[:, j], op, False))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@pytest.mark.parametrize("na_rm", [False, True])
|
|
399
|
+
def test_missing_and_infinite_values_settle_without_an_exact_pass(na_rm):
|
|
400
|
+
groups = [[1.0, np.nan, 2.0], [np.inf, 1.0], [-np.inf, 5.0], [np.inf, -np.inf], [np.nan, np.nan],
|
|
401
|
+
[np.inf, np.nan], [3e38, 3e38, 3e38], [1.0, 2.0, 3.0]]
|
|
402
|
+
values = np.concatenate([np.asarray(g, F32) for g in groups])
|
|
403
|
+
keys = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
|
|
404
|
+
for op in ("sum", "mean"):
|
|
405
|
+
calls = []
|
|
406
|
+
got = _chunked(values[:, None], keys, len(groups), op, [0, 2, 5, 9, 12, 20], na_rm=na_rm, calls=calls)[:, 0]
|
|
407
|
+
want = np.array([expected(np.asarray(g, F32), op, na_rm) for g in groups], F32)
|
|
408
|
+
assert same_bits(got, want), (op, got, want)
|
|
409
|
+
assert calls == []
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_min_count_and_empty_chunks_across_chunks():
|
|
413
|
+
values = np.asarray([1.0, np.nan, 2.0, np.nan, np.nan, 4.0], F32)
|
|
414
|
+
keys = np.asarray([0, 0, 0, 1, 1, 1])
|
|
415
|
+
for min_count, want in ((2, [3.0, np.nan]), (1, [3.0, 4.0])):
|
|
416
|
+
got = _chunked(values[:, None], keys, 2, "sum", [0, 0, 2, 2, 6, 6], na_rm=True, min_count=min_count)[:, 0]
|
|
417
|
+
assert same_bits(got, np.asarray(want, F32))
|
|
418
|
+
assert same_bits(_chunked(values[:, None], keys, 2, "sum", [0, 6], na_rm=False)[:, 0], np.asarray([np.nan, np.nan], F32))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@pytest.mark.parametrize("compiled", [False, True])
|
|
422
|
+
def test_chunks_take_the_numpy_path_and_match_the_compiled_in_memory_bits(compiled, monkeypatch):
|
|
423
|
+
pytest.importorskip("numba")
|
|
424
|
+
monkeypatch.setattr(sums, "COMPILED", compiled)
|
|
425
|
+
rng = np.random.default_rng(3)
|
|
426
|
+
values = (rng.normal(0, 1e3, 400_000)).astype(F32)
|
|
427
|
+
keys = rng.integers(0, 32, len(values))
|
|
428
|
+
want = _reduced(values, keys, 32, "sum") # compiled when installed and enabled: 400,000 values
|
|
429
|
+
assert same_bits(want, np.array([expected(values[keys == k], "sum", False) for k in range(32)], F32))
|
|
430
|
+
original = sums._compiled
|
|
431
|
+
monkeypatch.setattr(sums, "_compiled", lambda name: pytest.fail(f"a chunk used the compiled kernel {name}"))
|
|
432
|
+
bounds = [0, 150_000, 300_000, 400_000] # chunks big enough for it in memory; chunks run in parallel instead
|
|
433
|
+
for op in ("sum", "mean"):
|
|
434
|
+
got = _chunked(values[:, None], keys, 32, op, bounds)[:, 0]
|
|
435
|
+
monkeypatch.setattr(sums, "_compiled", original)
|
|
436
|
+
assert same_bits(got, _reduced(values, keys, 32, op))
|
|
437
|
+
monkeypatch.setattr(sums, "_compiled", lambda name: pytest.fail(f"a chunk used the compiled kernel {name}"))
|
labelarray-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.0 — 2026-10-25
|
|
4
|
-
|
|
5
|
-
The first release on its own. labelarray was developed as the array layer
|
|
6
|
-
of tidy3a (`tidy3a.labelarray`) and leaves it unchanged in behaviour:
|
|
7
|
-
|
|
8
|
-
- `Array`: a NumPy array with named axes, exact labels, attributes, and a
|
|
9
|
-
name. Selection by positions and labels (`isel`, `sel`), `transpose`,
|
|
10
|
-
`rename`, reductions (`reduce`), and contraction (`dot`). NumPy's ufuncs,
|
|
11
|
-
`np.where`, and Python's operators broadcast by axis name under the exact
|
|
12
|
-
alignment rule.
|
|
13
|
-
- `Collection`: arrays sharing axes, one label array per axis, with
|
|
14
|
-
selection, assignment, joins (`join`), nearest-label matching
|
|
15
|
-
(`nearest`), and conversion to and from xarray.
|
|
16
|
-
- The exact label rule (`labels`) and label indexes: sorted, sort-once,
|
|
17
|
-
and grid indexes, chosen by `index_for`, with `register_index` for other
|
|
18
|
-
label types.
|
|
19
|
-
- Grouping: `Grouping`, `factorize`, the key transforms `Bins`,
|
|
20
|
-
`Duration`, `Round`, and `GridCells`, and grouped reductions
|
|
21
|
-
(`grouped.reduce`, `counts`, `broadcast`) bit-identical to reducing each
|
|
22
|
-
group on its own.
|
|
23
|
-
- Float32 sums and means correctly rounded in any memory layout (`sums`),
|
|
24
|
-
with an optional compiled path (`labelarray[fast]`) giving the same bits.
|
|
25
|
-
- One family of errors under `LabelArrayError`.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|