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.
Files changed (40) hide show
  1. {labelarray-0.1.1 → labelarray-0.1.2}/CHANGELOG.md +17 -0
  2. {labelarray-0.1.1/src/labelarray.egg-info → labelarray-0.1.2}/PKG-INFO +5 -3
  3. {labelarray-0.1.1 → labelarray-0.1.2}/README.md +4 -2
  4. labelarray-0.1.2/src/labelarray/__version__.py +1 -0
  5. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/sums.py +193 -16
  6. {labelarray-0.1.1 → labelarray-0.1.2/src/labelarray.egg-info}/PKG-INFO +5 -3
  7. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_sums.py +117 -1
  8. labelarray-0.1.1/src/labelarray/__version__.py +0 -1
  9. {labelarray-0.1.1 → labelarray-0.1.2}/LICENSE +0 -0
  10. {labelarray-0.1.1 → labelarray-0.1.2}/MANIFEST.in +0 -0
  11. {labelarray-0.1.1 → labelarray-0.1.2}/docs/releasing.md +0 -0
  12. {labelarray-0.1.1 → labelarray-0.1.2}/pyproject.toml +0 -0
  13. {labelarray-0.1.1 → labelarray-0.1.2}/scripts/check.sh +0 -0
  14. {labelarray-0.1.1 → labelarray-0.1.2}/setup.cfg +0 -0
  15. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/__init__.py +0 -0
  16. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/_compiled.py +0 -0
  17. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/align.py +0 -0
  18. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/array.py +0 -0
  19. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/collection.py +0 -0
  20. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/contract.py +0 -0
  21. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/errors.py +0 -0
  22. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/grouped.py +0 -0
  23. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/index.py +0 -0
  24. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/labels.py +0 -0
  25. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/py.typed +0 -0
  26. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray/transforms.py +0 -0
  27. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray.egg-info/SOURCES.txt +0 -0
  28. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray.egg-info/dependency_links.txt +0 -0
  29. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray.egg-info/requires.txt +0 -0
  30. {labelarray-0.1.1 → labelarray-0.1.2}/src/labelarray.egg-info/top_level.txt +0 -0
  31. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_array.py +0 -0
  32. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_collection.py +0 -0
  33. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_contraction.py +0 -0
  34. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_grouping.py +0 -0
  35. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_label_indexes.py +0 -0
  36. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_label_lookups.py +0 -0
  37. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_library_errors.py +0 -0
  38. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_readme.py +0 -0
  39. {labelarray-0.1.1 → labelarray-0.1.2}/tests/test_standalone.py +0 -0
  40. {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.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.1`; the API may still
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.1`; the API may still
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 = np.transpose(values, axes + kept).reshape(positions, math.prod(kept_shape))
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 (the buckets shifted into place), and where it is below 2**52.
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
- shifts = np.arange(buckets.shape[1])
297
- bound = (np.abs(buckets).astype(np.float64) * np.ldexp(1.0, shifts)).sum(axis=1) # exact terms; rounding is small
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(len(buckets), dtype=np.int64)
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(buckets[fits], np.minimum(shifts, 62)).sum(axis=1)
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: int, divisor: np.ndarray) -> np.ndarray:
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.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.1`; the API may still
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