labelarray 0.1.0__tar.gz → 0.1.1__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.0 → labelarray-0.1.1}/CHANGELOG.md +16 -0
  2. {labelarray-0.1.0/src/labelarray.egg-info → labelarray-0.1.1}/PKG-INFO +2 -2
  3. {labelarray-0.1.0 → labelarray-0.1.1}/README.md +1 -1
  4. labelarray-0.1.1/src/labelarray/__version__.py +1 -0
  5. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/sums.py +84 -29
  6. {labelarray-0.1.0 → labelarray-0.1.1/src/labelarray.egg-info}/PKG-INFO +2 -2
  7. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_sums.py +88 -2
  8. labelarray-0.1.0/src/labelarray/__version__.py +0 -1
  9. {labelarray-0.1.0 → labelarray-0.1.1}/LICENSE +0 -0
  10. {labelarray-0.1.0 → labelarray-0.1.1}/MANIFEST.in +0 -0
  11. {labelarray-0.1.0 → labelarray-0.1.1}/docs/releasing.md +0 -0
  12. {labelarray-0.1.0 → labelarray-0.1.1}/pyproject.toml +0 -0
  13. {labelarray-0.1.0 → labelarray-0.1.1}/scripts/check.sh +0 -0
  14. {labelarray-0.1.0 → labelarray-0.1.1}/setup.cfg +0 -0
  15. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/__init__.py +0 -0
  16. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/_compiled.py +0 -0
  17. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/align.py +0 -0
  18. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/array.py +0 -0
  19. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/collection.py +0 -0
  20. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/contract.py +0 -0
  21. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/errors.py +0 -0
  22. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/grouped.py +0 -0
  23. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/index.py +0 -0
  24. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/labels.py +0 -0
  25. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/py.typed +0 -0
  26. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray/transforms.py +0 -0
  27. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray.egg-info/SOURCES.txt +0 -0
  28. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray.egg-info/dependency_links.txt +0 -0
  29. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray.egg-info/requires.txt +0 -0
  30. {labelarray-0.1.0 → labelarray-0.1.1}/src/labelarray.egg-info/top_level.txt +0 -0
  31. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_array.py +0 -0
  32. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_collection.py +0 -0
  33. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_contraction.py +0 -0
  34. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_grouping.py +0 -0
  35. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_label_indexes.py +0 -0
  36. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_label_lookups.py +0 -0
  37. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_library_errors.py +0 -0
  38. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_readme.py +0 -0
  39. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_standalone.py +0 -0
  40. {labelarray-0.1.0 → labelarray-0.1.1}/tests/test_transforms.py +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 — 2026-10-26
4
+
5
+ - Grouped float32 sums and means over many small groups are much faster:
6
+ 200,000 values in 96,846 groups (voxels holding one to a few points) now
7
+ take 6 ms instead of 111. The exact mean of a few float32 values often
8
+ lies exactly halfway between two float32 values, where the error bound
9
+ cannot decide the rounding; about one group in ten went to an exact
10
+ path computed in Python fractions. Groups whose values span a narrow
11
+ range of exponents now have their exact sums computed as 64-bit integers
12
+ and rounded with NumPy (integer division kept to 27 or 28 bits with the
13
+ last bit set when anything is left over, then one rounding to float32),
14
+ so the Python path is left for groups spanning a wide range. Results are
15
+ unchanged, bit for bit: the tests compare them with exact rational
16
+ arithmetic, and the new code gave the same bits as 0.1.0 on two million
17
+ results.
18
+
3
19
  ## 0.1.0 — 2026-10-25
4
20
 
5
21
  The first release on its own. labelarray was developed as the array layer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labelarray
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
@@ -46,7 +46,7 @@ It is deliberately small. What it offers that other array libraries do not:
46
46
  3. **Collections.** Arrays sharing axes, with one label array per axis,
47
47
  joined and aligned under those rules.
48
48
 
49
- **Status:** version `0.1.0`, the first release on its own; the API may still
49
+ **Status:** version `0.1.1`; the API may still
50
50
  change before 1.0. What changed in each release is in
51
51
  [CHANGELOG.md](CHANGELOG.md).
52
52
 
@@ -18,7 +18,7 @@ It is deliberately small. What it offers that other array libraries do not:
18
18
  3. **Collections.** Arrays sharing axes, with one label array per axis,
19
19
  joined and aligned under those rules.
20
20
 
21
- **Status:** version `0.1.0`, the first release on its own; the API may still
21
+ **Status:** version `0.1.1`; the API may still
22
22
  change before 1.0. What changed in each release is in
23
23
  [CHANGELOG.md](CHANGELOG.md).
24
24
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -214,7 +214,14 @@ def _certify(total, magnitude, terms, counts, op, compensated):
214
214
 
215
215
 
216
216
  def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
217
- """Correctly rounded results for groups *chosen*, from exact sums of their values."""
217
+ """Correctly rounded results for groups *chosen*, from exact sums of their values.
218
+
219
+ Most such groups are small and hold values of nearby magnitudes (their
220
+ exact mean lies on a float32 tie, where the bound cannot decide): their
221
+ exact sums fit in 64-bit integers and are rounded with NumPy
222
+ (``_rounded``). Groups spanning a wider range of exponents are summed and
223
+ rounded in Python integers and fractions.
224
+ """
218
225
  if rows is None:
219
226
  part, codes, count = values, np.zeros(len(values), dtype=np.intp), 1
220
227
  else:
@@ -223,27 +230,35 @@ def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
223
230
  codes = remap[rows]
224
231
  keep = codes >= 0
225
232
  part, codes, count = values[keep], codes[keep], len(chosen)
226
- totals, present, special = _exact_sums(part, codes, count, na_rm)
227
- out = np.empty(count, dtype=np.float32)
228
- for k in range(count):
229
- if special[k] is not None:
230
- out[k] = special[k]
231
- elif op == "sum":
232
- out[k] = _round32(totals[k])
233
- else:
234
- out[k] = _round32(totals[k] / present[k]) if present[k] else np.float32(np.nan)
233
+ buckets, low, present, nan, plus, minus = _exact_sums(part, codes, count)
234
+ out = np.full(count, np.nan, dtype=np.float32)
235
+ undefined = (nan & (not na_rm)) | (plus & minus)
236
+ infinite = (plus | minus) & ~undefined
237
+ out[infinite] = np.where(plus[infinite], np.float32(np.inf), np.float32(-np.inf))
238
+ regular = ~(undefined | infinite)
235
239
  if op == "mean":
236
- out[(present == 0) & np.array([s is None for s in special])] = np.nan
240
+ regular &= present > 0 # a mean of no values stays NaN
241
+ divisor = present if op == "mean" else np.ones(count, dtype=np.int64)
242
+ whole, fits = _whole(buckets)
243
+ fast = regular & fits & (divisor < 2**35) # so the integer quotient below stays within int64
244
+ if fast.any():
245
+ out[fast] = _rounded(whole[fast], low - 150, divisor[fast])
246
+ for k in np.flatnonzero(regular & ~fits):
247
+ row = buckets[k]
248
+ total = sum(int(row[j]) << int(j) for j in np.flatnonzero(row)) * Fraction(2) ** (low - 150)
249
+ out[k] = _round32(total / int(divisor[k]))
237
250
  return out
238
251
 
239
252
 
240
- def _exact_sums(values, codes, count, na_rm):
241
- """Exact sums as Fractions, values present, and NaN or infinity where they decide the result.
253
+ def _exact_sums(values, codes, count):
254
+ """Exact sums as integer buckets per group and exponent, the lowest exponent, values present, and specials.
242
255
 
243
256
  A float32 is M * 2**(e - 150) with M a signed integer below 2**24 and e
244
257
  from 1 to 254. Integers M with the same e add exactly in float64 for
245
258
  fewer than 2**29 values, so they are summed per group and exponent with
246
- ``np.bincount``, and the few totals per group combined in Python integers.
259
+ ``np.bincount``: group k's exact sum is the sum over j of
260
+ ``buckets[k, j] * 2**(low + j - 150)``. Also, per group, the values
261
+ present (not NaN), and whether a NaN, +inf, or -inf is among them.
247
262
  """
248
263
  bits = values.view(np.uint32)
249
264
  exponent = ((bits >> 23) & 0xFF).astype(np.intp)
@@ -257,30 +272,70 @@ def _exact_sums(values, codes, count, na_rm):
257
272
  has_nan = np.bincount(codes, weights=nan, minlength=count) > 0
258
273
  plus = np.bincount(codes, weights=values == np.inf, minlength=count) > 0
259
274
  minus = np.bincount(codes, weights=values == -np.inf, minlength=count) > 0
260
- totals = [Fraction(0)] * count
275
+ low, width = 1, 1
261
276
  if finite.any():
262
277
  low, high = int(exponent[finite].min()), int(exponent[finite].max())
263
278
  width = high - low + 1
279
+ buckets = np.zeros(count * width, dtype=np.int64)
280
+ if finite.any():
264
281
  key = codes[finite] * width + (exponent[finite] - low)
265
282
  weights = mantissa[finite].astype(np.float64)
266
- buckets = np.zeros(count * width, dtype=np.int64)
267
283
  chunk = 1 << 28 # each bucket sum stays below 2**52, so float64 holds it exactly
268
284
  for start in range(0, len(key), chunk):
269
285
  buckets += np.bincount(key[start:start + chunk], weights=weights[start:start + chunk],
270
286
  minlength=count * width).astype(np.int64)
271
- buckets = buckets.reshape(count, width)
272
- scale = Fraction(2) ** (low - 150)
273
- for k in range(count):
274
- row = buckets[k]
275
- whole = sum(int(row[j]) << int(j) for j in np.flatnonzero(row)) # Python integers: shifts beyond 63
276
- totals[k] = whole * scale
277
- special = [None] * count
278
- for k in range(count):
279
- if (has_nan[k] and not na_rm) or (plus[k] and minus[k]):
280
- special[k] = np.float32(np.nan)
281
- elif plus[k] or minus[k]:
282
- special[k] = np.float32(np.inf if plus[k] else -np.inf)
283
- return totals, present, special
287
+ return buckets.reshape(count, width), low, present, has_nan, plus, minus
288
+
289
+
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.
292
+
293
+ 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.
295
+ """
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
298
+ fits = bound < 2.0**52
299
+ whole = np.zeros(len(buckets), dtype=np.int64)
300
+ if fits.any():
301
+ # 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
304
+
305
+
306
+ def _rounded(whole: np.ndarray, scale: int, divisor: np.ndarray) -> np.ndarray:
307
+ """``whole * 2**scale / divisor`` correctly rounded to float32, ties to even, for |whole| below 2**52.
308
+
309
+ The quotient is taken in integers to 27 or 28 significant bits, with its
310
+ last bit set when anything was left over ("round to odd"); with at least
311
+ two bits more than float32 keeps, the one rounding from float64 to
312
+ float32 then rounds as the exact quotient would, subnormals included.
313
+ """
314
+ sign = np.where(whole < 0, -1.0, 1.0)
315
+ size = np.abs(whole)
316
+ divisor = divisor.astype(np.int64)
317
+ zero = size == 0
318
+ size = np.where(zero, 1, size) # placeholders; zero sums are +0.0, as NumPy gives them
319
+ size_bits = np.frexp(size.astype(np.float64))[1].astype(np.int64) # bit lengths, exact below 2**53
320
+ divisor_bits = np.frexp(divisor.astype(np.float64))[1].astype(np.int64)
321
+ up = 27 + divisor_bits - size_bits # shift so the quotient has 27 or 28 bits
322
+ quotient = np.empty(len(size), dtype=np.int64)
323
+ rest = np.empty(len(size), dtype=bool)
324
+ left = up >= 0
325
+ if left.any(): # size << up has 27 or 28 more bits than the divisor: below 2**63 for divisors below 2**35
326
+ numerator = np.left_shift(size[left], up[left])
327
+ quotient[left], remainder = np.divmod(numerator, divisor[left])
328
+ rest[left] = remainder != 0
329
+ right = ~left
330
+ if right.any():
331
+ whole_part, remainder = np.divmod(size[right], divisor[right])
332
+ down = -up[right]
333
+ quotient[right] = np.right_shift(whole_part, down)
334
+ rest[right] = (remainder != 0) | ((whole_part & ((np.int64(1) << down) - 1)) != 0)
335
+ quotient |= rest.astype(np.int64)
336
+ result = np.ldexp(sign * quotient.astype(np.float64), scale - up).astype(np.float32)
337
+ result[zero] = np.float32(0.0)
338
+ return result
284
339
 
285
340
 
286
341
  def _round32(value: Fraction) -> np.float32:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labelarray
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
@@ -46,7 +46,7 @@ It is deliberately small. What it offers that other array libraries do not:
46
46
  3. **Collections.** Arrays sharing axes, with one label array per axis,
47
47
  joined and aligned under those rules.
48
48
 
49
- **Status:** version `0.1.0`, the first release on its own; the API may still
49
+ **Status:** version `0.1.1`; the API may still
50
50
  change before 1.0. What changed in each release is in
51
51
  [CHANGELOG.md](CHANGELOG.md).
52
52
 
@@ -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
- a, b = np.asarray(a, F32), np.asarray(b, F32)
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 bool(np.all(both_nan | (a.view(np.uint32) == b.view(np.uint32))))
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,84 @@ 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
+ values = np.concatenate([np.asarray(g, F32) for g in groups])
314
+ keys = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
315
+ calls = []
316
+ original = sums._round32
317
+ monkeypatch.setattr(sums, "_round32", lambda value: calls.append(value) or original(value))
318
+ for op in ("sum", "mean"):
319
+ got = _reduced(values, keys, len(groups), op)
320
+ assert same_bits(got, np.array([expected(np.asarray(g, F32), op, False) for g in groups], F32))
321
+ assert calls # these need the exact path in Python integers
@@ -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