labelarray 0.1.1__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.1 → labelarray-0.1.2}/CHANGELOG.md +17 -0
- {labelarray-0.1.1/src/labelarray.egg-info → labelarray-0.1.2}/PKG-INFO +5 -3
- {labelarray-0.1.1 → labelarray-0.1.2}/README.md +4 -2
- labelarray-0.1.2/src/labelarray/__version__.py +1 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/sums.py +193 -16
- {labelarray-0.1.1 → labelarray-0.1.2/src/labelarray.egg-info}/PKG-INFO +5 -3
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_sums.py +117 -1
- labelarray-0.1.1/src/labelarray/__version__.py +0 -1
- {labelarray-0.1.1 → labelarray-0.1.2}/LICENSE +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/MANIFEST.in +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/docs/releasing.md +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/pyproject.toml +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/scripts/check.sh +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/setup.cfg +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/__init__.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/_compiled.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/align.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/array.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/collection.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/contract.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/errors.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/grouped.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/index.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/labels.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/py.typed +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/transforms.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray.egg-info/SOURCES.txt +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray.egg-info/dependency_links.txt +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray.egg-info/requires.txt +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray.egg-info/top_level.txt +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_array.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_collection.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_contraction.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_grouping.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_label_indexes.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_label_lookups.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_library_errors.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_readme.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_standalone.py +0 -0
- {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_transforms.py +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
3
20
|
## 0.1.1 — 2026-10-26
|
|
4
21
|
|
|
5
22
|
- Grouped float32 sums and means over many small groups are much faster:
|
|
@@ -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)
|
|
@@ -231,6 +250,12 @@ def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
|
|
|
231
250
|
keep = codes >= 0
|
|
232
251
|
part, codes, count = values[keep], codes[keep], len(chosen)
|
|
233
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)
|
|
234
259
|
out = np.full(count, np.nan, dtype=np.float32)
|
|
235
260
|
undefined = (nan & (not na_rm)) | (plus & minus)
|
|
236
261
|
infinite = (plus | minus) & ~undefined
|
|
@@ -239,10 +264,10 @@ def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
|
|
|
239
264
|
if op == "mean":
|
|
240
265
|
regular &= present > 0 # a mean of no values stays NaN
|
|
241
266
|
divisor = present if op == "mean" else np.ones(count, dtype=np.int64)
|
|
242
|
-
whole, fits = _whole(buckets)
|
|
267
|
+
whole, first, fits = _whole(buckets)
|
|
243
268
|
fast = regular & fits & (divisor < 2**35) # so the integer quotient below stays within int64
|
|
244
269
|
if fast.any():
|
|
245
|
-
out[fast] = _rounded(whole[fast], low - 150, divisor[fast])
|
|
270
|
+
out[fast] = _rounded(whole[fast], low + first[fast] - 150, divisor[fast])
|
|
246
271
|
for k in np.flatnonzero(regular & ~fits):
|
|
247
272
|
row = buckets[k]
|
|
248
273
|
total = sum(int(row[j]) << int(j) for j in np.flatnonzero(row)) * Fraction(2) ** (low - 150)
|
|
@@ -287,23 +312,30 @@ def _exact_sums(values, codes, count):
|
|
|
287
312
|
return buckets.reshape(count, width), low, present, has_nan, plus, minus
|
|
288
313
|
|
|
289
314
|
|
|
290
|
-
def _whole(buckets: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
291
|
-
"""Each group's exact sum as one integer
|
|
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.
|
|
292
317
|
|
|
293
318
|
Where it is, the shifted buckets and their sum stay within int64 and the
|
|
294
|
-
sum is exact in float64 too; other groups need Python integers.
|
|
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.
|
|
295
322
|
"""
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
298
330
|
fits = bound < 2.0**52
|
|
299
|
-
whole = np.zeros(
|
|
331
|
+
whole = np.zeros(count, dtype=np.int64)
|
|
300
332
|
if fits.any():
|
|
301
333
|
# Shifts of zero buckets beyond 62 bits are clamped; a nonzero bucket that far up does not fit.
|
|
302
|
-
whole[fits] = np.left_shift(
|
|
303
|
-
return whole, fits
|
|
334
|
+
whole[fits] = np.left_shift(aligned[fits], np.minimum(shifts, 62)).sum(axis=1)
|
|
335
|
+
return whole, first, fits
|
|
304
336
|
|
|
305
337
|
|
|
306
|
-
def _rounded(whole: np.ndarray, scale
|
|
338
|
+
def _rounded(whole: np.ndarray, scale, divisor: np.ndarray) -> np.ndarray:
|
|
307
339
|
"""``whole * 2**scale / divisor`` correctly rounded to float32, ties to even, for |whole| below 2**52.
|
|
308
340
|
|
|
309
341
|
The quotient is taken in integers to 27 or 28 significant bits, with its
|
|
@@ -355,6 +387,151 @@ def _round32(value: Fraction) -> np.float32:
|
|
|
355
387
|
return np.float32(best)
|
|
356
388
|
|
|
357
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
|
+
|
|
358
535
|
# ── The optional compiled kernel ────────────────────────────────────────────
|
|
359
536
|
|
|
360
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
|
|
|
@@ -309,7 +309,8 @@ def test_narrow_groups_are_decided_without_fractions(monkeypatch):
|
|
|
309
309
|
|
|
310
310
|
def test_groups_spanning_wide_exponents_still_add_exactly(monkeypatch):
|
|
311
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)]
|
|
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
|
|
313
314
|
values = np.concatenate([np.asarray(g, F32) for g in groups])
|
|
314
315
|
keys = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
|
|
315
316
|
calls = []
|
|
@@ -319,3 +320,118 @@ def test_groups_spanning_wide_exponents_still_add_exactly(monkeypatch):
|
|
|
319
320
|
got = _reduced(values, keys, len(groups), op)
|
|
320
321
|
assert same_bits(got, np.array([expected(np.asarray(g, F32), op, False) for g in groups], F32))
|
|
321
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}"))
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.1"
|
|
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
|