Skip to content

Commit 2dfb86b

Browse files
committed
blosc2.random: add permutation, permuted, and shuffle
Unlike the rest of the module these are not chunk-parallel: whole-array shuffling is inherently sequential, so each call materializes the full array into memory and shuffles it single-threaded with one spawned SeedSequence stream, then writes the result back out. shuffle additionally requires its argument to already be a blosc2.NDArray, since it mutates in place and returns None to match numpy's contract. Brings coverage of numpy.random.Generator's public methods to 42/43 (only bytes, which returns raw bytes rather than an NDArray, remains out of scope). Docs, coverage table, and RELEASE_NOTES updated to match.
1 parent e83249f commit 2dfb86b

4 files changed

Lines changed: 114 additions & 10 deletions

File tree

‎RELEASE_NOTES.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ XXX version-specific blurb XXX
1111
stream and is generated concurrently in a thread pool, giving full `PCG64`
1212
quality with genuinely parallel generation (measured ~3x faster than
1313
`asarray(np.random.default_rng(...).random(...))` on a 100M-element array).
14-
Covers 39 of `numpy.random.Generator`'s 43 public methods (full
14+
Covers 42 of `numpy.random.Generator`'s 43 public methods (full
1515
compatibility table in `doc/reference/random.rst`):
1616
- Core: `random`, `integers`, `normal`, `uniform`, `choice`
1717
(`replace=True` only).
@@ -25,9 +25,13 @@ XXX version-specific blurb XXX
2525
- 4 vector-valued distributions (output shape `shape + (k,)`, one draw
2626
per trailing vector): `dirichlet`, `multinomial`,
2727
`multivariate_hypergeometric`, `multivariate_normal`.
28-
- Not implemented: `permutation`/`permuted`/`shuffle` (whole-array
29-
shuffling needs cross-chunk coordination this module doesn't do) and
30-
`bytes` (returns raw `bytes`, not an `NDArray`).
28+
- `permutation`, `permuted`, `shuffle`: unlike the rest of the module,
29+
these are not chunk-parallel — whole-array shuffling is inherently
30+
sequential, so they materialize the full array and shuffle it
31+
single-threaded. `shuffle` additionally requires its argument to
32+
already be an `NDArray`, since it mutates in place and returns `None`,
33+
matching numpy.
34+
- Not implemented: `bytes` (returns raw `bytes`, not an `NDArray`).
3135

3236
## Changes from 4.9.0 to 4.9.1
3337

‎doc/reference/random.rst‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ consistently across the module:
4343
:meth:`~Generator.multivariate_normal`) draw one length-``k`` vector per element:
4444
output shape is ``shape + (k,)``, and that trailing dimension is always kept whole
4545
within a chunk.
46+
- :meth:`~Generator.permutation`, :meth:`~Generator.permuted`, and
47+
:meth:`~Generator.shuffle` are **not** chunk-parallel: whole-array shuffling is
48+
inherently sequential, so these load the full array into memory and shuffle it
49+
single-threaded rather than generating chunks independently. :meth:`~Generator.shuffle`
50+
additionally requires its argument to already be a :class:`~blosc2.NDArray`, since it
51+
mutates it in place and returns ``None``, matching numpy.
4652

4753
Coverage of :class:`numpy.random.Generator`'s public methods:
4854

@@ -129,11 +135,11 @@ Coverage of :class:`numpy.random.Generator`'s public methods:
129135
- ✅
130136
-
131137
* - ``permutation``
132-
- ❌
133-
- whole-array shuffle is inherently sequential, not per-chunk independent
138+
- ✅
139+
- single-threaded, full-materialization — see above
134140
* - ``permuted``
135-
- ❌
136-
- same as ``permutation``
141+
- ✅
142+
- single-threaded, full-materialization — see above
137143
* - ``poisson``
138144
- ✅
139145
-
@@ -147,8 +153,8 @@ Coverage of :class:`numpy.random.Generator`'s public methods:
147153
- ✅
148154
-
149155
* - ``shuffle``
150-
- ❌
151-
- same as ``permutation``
156+
- ✅
157+
- single-threaded, full-materialization; requires an ``NDArray`` — see above
152158
* - ``standard_cauchy``
153159
- ✅
154160
-

‎src/blosc2/random.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ def _chunk_slices(a: blosc2.NDArray):
3232
)
3333

3434

35+
def _materialize(x) -> np.ndarray:
36+
return x[:] if isinstance(x, blosc2.NDArray) else np.asarray(x)
37+
38+
3539
def _bounded_map(fn, n: int, max_workers: int):
3640
# ponytail: window bounds peak memory to O(workers * chunk); a plain
3741
# executor.map would submit all n chunks upfront and could buffer O(n).
@@ -314,6 +318,42 @@ def multivariate_normal(self, mean, cov, *, shape, **kwargs: Any) -> blosc2.NDAr
314318
"""
315319
return self._fill_vector("multivariate_normal", (mean, cov), {}, shape, len(mean), **kwargs)
316320

321+
def permutation(self, x, *, axis: int = 0, **kwargs: Any) -> blosc2.NDArray:
322+
"""Return a shuffled copy of ``x`` (or of ``arange(x)`` if ``x`` is an int).
323+
324+
Mirrors :meth:`numpy.random.Generator.permutation`.
325+
326+
Unlike the rest of this module, permuting is inherently sequential: it loads
327+
``x`` fully into memory and shuffles it single-threaded, rather than generating
328+
chunks independently in parallel.
329+
"""
330+
rng = np.random.default_rng(self._root.spawn(1)[0])
331+
arr = np.arange(x) if isinstance(x, int | np.integer) else _materialize(x)
332+
return blosc2.asarray(rng.permutation(arr, axis=axis), **kwargs)
333+
334+
def permuted(self, x, *, axis: int | None = None, **kwargs: Any) -> blosc2.NDArray:
335+
"""Return a copy of ``x`` with elements permuted along ``axis`` (flattened if ``None``).
336+
337+
Mirrors :meth:`numpy.random.Generator.permuted`. See :meth:`permutation` for the
338+
single-threaded, full-materialization caveat.
339+
"""
340+
rng = np.random.default_rng(self._root.spawn(1)[0])
341+
return blosc2.asarray(rng.permuted(_materialize(x), axis=axis), **kwargs)
342+
343+
def shuffle(self, x: blosc2.NDArray, *, axis: int = 0) -> None:
344+
"""Shuffle ``x`` in place along ``axis``. Mirrors :meth:`numpy.random.Generator.shuffle`.
345+
346+
``x`` must be a :class:`~blosc2.NDArray`: its full contents are read into memory,
347+
shuffled single-threaded (see :meth:`permutation`), and written back. Returns
348+
``None``, matching numpy.
349+
"""
350+
if not isinstance(x, blosc2.NDArray):
351+
raise TypeError("blosc2.random.Generator.shuffle requires a blosc2.NDArray to shuffle in place")
352+
rng = np.random.default_rng(self._root.spawn(1)[0])
353+
arr = x[:]
354+
rng.shuffle(arr, axis=axis)
355+
x[:] = arr
356+
317357

318358
def default_rng(seed=None) -> Generator:
319359
"""Construct a chunk-parallel :class:`Generator`, mirroring :func:`numpy.random.default_rng`.

‎tests/test_random.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,57 @@ def test_vector_dist_chunks_mismatch_raises():
213213
def test_vector_dist_scalar_leading_shape():
214214
a = blosc2.random.default_rng(0).dirichlet([1.0, 2.0, 3.0], shape=())
215215
assert a.shape == (3,)
216+
217+
218+
def test_permutation_int_is_a_permutation_and_reproducible():
219+
a = blosc2.random.default_rng(0).permutation(10)
220+
b = blosc2.random.default_rng(0).permutation(10)
221+
np.testing.assert_array_equal(a[:], b[:])
222+
assert sorted(a[:].tolist()) == list(range(10))
223+
224+
225+
def test_permutation_successive_calls_differ():
226+
rng = blosc2.random.default_rng(0)
227+
a = rng.permutation(1000)
228+
b = rng.permutation(1000)
229+
assert not np.array_equal(a[:], b[:])
230+
231+
232+
def test_permutation_array_axis():
233+
arr = blosc2.arange(12).reshape((3, 4))
234+
p = blosc2.random.default_rng(1).permutation(arr, axis=1)
235+
assert p.shape == (3, 4)
236+
for row_before, row_after in zip(arr[:], p[:], strict=True):
237+
assert sorted(row_after.tolist()) == sorted(row_before.tolist())
238+
239+
240+
def test_permutation_kwargs_passthrough():
241+
p = blosc2.random.default_rng(0).permutation(1000, chunks=(100,), cparams={"clevel": 5})
242+
assert p.chunks == (100,)
243+
assert p.schunk.cparams.clevel == 5
244+
245+
246+
def test_permuted_is_a_permutation():
247+
a = blosc2.random.default_rng(2).permuted(np.arange(10))
248+
assert sorted(a[:].tolist()) == list(range(10))
249+
250+
251+
def test_permuted_flattens_when_axis_none():
252+
a = blosc2.random.default_rng(2).permuted(blosc2.arange(12).reshape((3, 4)))
253+
assert a.shape == (3, 4)
254+
assert sorted(a[:].flatten().tolist()) == list(range(12))
255+
256+
257+
def test_shuffle_in_place_preserves_multiset():
258+
a = blosc2.arange(10)
259+
before = a[:].copy()
260+
result = blosc2.random.default_rng(3).shuffle(a)
261+
after = a[:]
262+
assert result is None
263+
assert sorted(before.tolist()) == sorted(after.tolist())
264+
assert not np.array_equal(before, after)
265+
266+
267+
def test_shuffle_requires_ndarray():
268+
with pytest.raises(TypeError):
269+
blosc2.random.default_rng(3).shuffle(np.arange(5))

0 commit comments

Comments
 (0)