Skip to content

Commit 4e55e0e

Browse files
committed
Small tweaks the prng doc
1 parent 911c6a3 commit 4e55e0e

File tree

1 file changed

+44
-62
lines changed

1 file changed

+44
-62
lines changed

doc/tutorial/prng.rst

+44-62
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ In the first line np.random.default_rng(seed) creates a random Generator.
3131
>>> rng # doctest: +SKIP
3232
Generator(PCG64) at 0x7F6C04535820
3333

34-
Every numpy Generator holds a BitGenerator, which is able to generate high-quality sequences of pseudo random bits.
35-
Numpy generators convert these sequences of bits into sequences of numbers that follow a specific statistical distribution.
34+
Every NumPy Generator holds a BitGenerator, which is able to generate high-quality sequences of pseudo random bits.
35+
NumPy generators' methods convert these sequences of bits into sequences of numbers that follow a specific statistical distribution.
3636
For more details, you can read `NumPy random sampling documentation <https://numpy.org/doc/stable/reference/random>`_.
3737

3838
>>> rng.bit_generator # doctest: +SKIP
@@ -47,6 +47,7 @@ For more details, you can read `NumPy random sampling documentation <https://num
4747

4848
When we call rng.uniform(size=2), the Generator class requested a new array of pseudo random bits (state) from the BitGenerator,
4949
and used a deterministic mapping function to convert those into a float64 numbers.
50+
5051
It did this twice, because we requested two draws via the size argument.
5152
In the long-run this deterministic mapping function should produce draws that are statistically indistinguishable from a true uniform distribution.
5253

@@ -71,7 +72,7 @@ array([0.033, 0.972, 0.459, 0.71 , 0.765])
7172
SciPy
7273
-----
7374

74-
Scipy wraps these Numpy routines in a slightly different API.
75+
SciPy wraps these NumPy routines in a slightly different API.
7576

7677
>>> import scipy.stats as st
7778
>>> rng = np.random.default_rng(seed=123)
@@ -82,7 +83,7 @@ PyTensor
8283
--------
8384

8485
PyTensor does not implement its own bit/generators methods.
85-
Just like Scipy, it borrows NumPy routines directly.
86+
Just like SciPy, it borrows NumPy routines directly.
8687

8788
The low-level API of PyTensor RNGs is similar to that of SciPy,
8889
whereas the higher-level API of RandomStreams is more like that of NumPy.
@@ -95,20 +96,19 @@ We will look at RandomStreams shortly, but we will start with the low-level API.
9596
>>> x = pt.random.uniform(size=2, rng=rng)
9697
>>> f = pytensor.function([rng], x)
9798

98-
We created a function that takes a Numpy RandomGenerator and returns two uniform draws. Let's evaluate it
99+
We created a function that takes a NumPy RandomGenerator and returns two uniform draws. Let's evaluate it
99100

100101
>>> rng_val = np.random.default_rng(123)
101102
>>> print(f(rng_val), f(rng_val))
102103
[0.68235186 0.05382102] [0.68235186 0.05382102]
103104

104-
The first numbers were exactly the same as the numpy and scipy calls, because we are using the very same routines.
105+
The first numbers were exactly the same as the NumPy and SciPy calls, because we are using the very same routines.
105106

106107
Perhaps surprisingly, we got the same results when we called the function the second time!
107108
This is because PyTensor functions do not hold an internal state and do not modify inputs inplace unless requested to.
108109

109-
We made sure that the rng_val was not modified when calling our Pytensor function, by copying it before using it.
110-
This may feel inefficient (and it is), but PyTensor is built on a pure functional approach, which is not allowed to have side-effects
111-
(such as changing global variables) by default.
110+
We made sure that the rng_val was not modified when calling our PyTensor function, by copying it before using it.
111+
This may feel inefficient (and it is), but PyTensor is built on a pure functional approach, which is not allowed to have side-effects by default.
112112

113113
We will later see how we can get around this issue by making the inputs mutable or using shared variables with explicit update rules.
114114

@@ -129,8 +129,8 @@ In this case we had to advance it twice to get two completely new draws, because
129129
But other distributions could need more states for a single draw, or they could be clever and reuse the same state for multiple draws.
130130

131131
Because it is not in generally possible to know how much one should modify the generator's bit generator,
132-
PyTensor RandomVariables actually return the copied generator as a hidden output.
133-
This copied generator can be safely used again because it contains the bit generator that was already modified when taking draws.
132+
PyTensor RandomVariables actually return the used generator as a hidden output.
133+
This generator can be safely used again because it contains the bit generator that was already modified when taking draws.
134134

135135
>>> next_rng, x = x.owner.outputs
136136
>>> next_rng.type, x.type
@@ -148,7 +148,6 @@ uniform_rv{"(),()->()"}.0 [id A] <RandomGeneratorType> 'next_rng'
148148
└─ 1.0 [id G] <Scalar(float32, shape=())>
149149
uniform_rv{"(),()->()"}.1 [id A] <Vector(float64, shape=(2,))> 'x'
150150
└─ ···
151-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
152151

153152
We can see the single node with [id A], has two outputs, which we named next_rng and x. By default only the second output x is given to the user directly, and the other is "hidden".
154153

@@ -226,14 +225,14 @@ This is exactly what RandomStream does behind the scenes
226225
>>> x.owner.inputs[0], x.owner.inputs[0].default_update # doctest: +SKIP
227226
(RNG(<Generator(PCG64) at 0x7FA45F4A3760>), uniform_rv{"(),()->()"}.0)
228227

229-
From the example here, you can see that RandomStream uses a NumPy-like API in contrast to
230-
the SciPy-like API of `pytensor.tensor.random`. Full documentation can be found at
231-
:doc:`../library/tensor/random/basic`.
232-
233228
>>> f = pytensor.function([], x)
234229
>>> print(f(), f(), f())
235230
0.19365083425294516 0.7541389670292019 0.2762903411491048
236231

232+
From the example here, you can see that RandomStream uses a NumPy-like API in contrast to
233+
the SciPy-like API of `pytensor.tensor.random`. Full documentation can be found at
234+
:doc:`libdoc_tensor_random_basic`.
235+
237236
Shared RNGs are created by default
238237
----------------------------------
239238

@@ -279,7 +278,7 @@ RandomStreams provide a helper method to achieve the same
279278
Inplace optimization
280279
====================
281280

282-
As mentioned before, by default RandomVariables return a copy of the next RNG state, which can be quite slow.
281+
As mentioned, RandomVariable Ops default to making a copy of the input RNG before using it, which can be quite slow.
283282

284283
>>> rng = np.random.default_rng(123)
285284
>>> rng_shared = pytensor.shared(rng, name="rng")
@@ -291,13 +290,13 @@ uniform_rv{"(),()->()"}.1 [id A] 'x' 0
291290
├─ NoneConst{None} [id C]
292291
├─ 0.0 [id D]
293292
└─ 1.0 [id E]
294-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
293+
295294

296295
>>> %timeit f() # doctest: +SKIP
297-
169 µs ± 24.6 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
296+
81.8 µs ± 15.4 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
298297

299298
>>> %timeit rng.uniform() # doctest: +SKIP
300-
3.56 µs ± 106 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
299+
2.15 µs ± 63.4 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
301300

302301
Like other PyTensor operators, RandomVariable's can be given permission to modify inputs inplace during their operation.
303302

@@ -307,16 +306,6 @@ If the flag is set, the RNG will not be copied before taking random draws.
307306
>>> x.owner.op.inplace
308307
False
309308

310-
This flag is printed as the last argument of the Op in the `dprint`
311-
312-
>>> pytensor.dprint(x) # doctest: +SKIP
313-
uniform_rv{"(),()->()"}.1 [id A] 'x' 0
314-
├─ rng [id B]
315-
├─ NoneConst{None} [id C]
316-
├─ 0.0 [id D]
317-
└─ 1.0 [id E]
318-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
319-
320309
For illustration purposes, we will subclass the Uniform Op class and set inplace to True by default.
321310

322311
Users should never do this directly!
@@ -336,27 +325,21 @@ uniform_rv{"(),()->()"}.1 [id A] d={0: [0]} 0
336325
├─ NoneConst{None} [id C]
337326
├─ 0.0 [id D]
338327
└─ 1.0 [id E]
339-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
340328

341-
The destroy map annotation tells us that the first output of the x variable is allowed to alter the first input.
329+
The destroy map annotation tells us that the first output of the x variable is allowed to modify the first input.
342330

343331
>>> %timeit inplace_f() # doctest: +SKIP
344-
35.5 µs ± 1.87 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
332+
9.71 µs ± 2.06 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)
345333

346-
Performance is now much closer to calling numpy directly, with only a small overhead introduced by the PyTensor function.
334+
Performance is now much closer to calling NumPy directly, with a small overhead introduced by the PyTensor function.
347335

348336
The `random_make_inplace <https://github.com/pymc-devs/pytensor/blob/3fcf6369d013c597a9c964b2400a3c5e20aa8dce/pytensor/tensor/random/rewriting/basic.py#L42-L52>`_
349337
rewrite automatically replaces RandomVariable Ops by their inplace counterparts, when such operation is deemed safe. This happens when:
350338

351339
#. An input RNG is flagged as `mutable` and is used in not used anywhere else.
352-
#. A RNG is created intermediately and used in not used anywhere else.
340+
#. A RNG is created intermediately and not used anywhere else.
353341

354-
The first case is true when a users uses the `mutable` `kwarg` directly, or much more commonly,
355-
when a shared RNG is used and a (default or manual) update expression is given.
356-
In this case, a RandomVariable is allowed to modify the RNG because the shared variable holding it will be rewritten anyway.
357-
358-
The second case is not very common, because RNGs are not usually chained across multiple RandomVariable Ops.
359-
See more details in the next section.
342+
The first case is true when a users uses the `mutable` `kwarg` directly.
360343

361344
>>> from pytensor.compile.io import In
362345
>>> rng = pt.random.type.RandomGeneratorType()("rng")
@@ -371,7 +354,9 @@ uniform_rv{"(),()->()"}.1 [id A] d={0: [0]} 0
371354
├─ NoneConst{None} [id C]
372355
├─ 0.0 [id D]
373356
└─ 1.0 [id E]
374-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
357+
358+
Or, much more commonly, when a shared RNG is used and a (default or manual) update expression is given.
359+
In this case, a RandomVariable is allowed to modify the RNG because the shared variable holding it will be rewritten anyway.
375360

376361
>>> rng = pytensor.shared(np.random.default_rng(), name="rng")
377362
>>> next_rng, x = pt.random.uniform(rng=rng).owner.outputs
@@ -385,7 +370,9 @@ uniform_rv{"(),()->()"}.1 [id A] d={0: [0]} 0
385370
└─ 1.0 [id E]
386371
uniform_rv{"(),()->()"}.0 [id A] d={0: [0]} 0
387372
└─ ···
388-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
373+
374+
The second case is not very common, because RNGs are not usually chained across multiple RandomVariable Ops.
375+
See more details in the next section.
389376

390377
Multiple random variables
391378
=========================
@@ -420,7 +407,6 @@ normal_rv{"(),()->()"}.0 [id A] <RandomGeneratorType> 'next_rng_x' 0
420407
└─ ···
421408
normal_rv{"(),()->()"}.0 [id F] <RandomGeneratorType> 'next_rng_y' 1
422409
└─ ···
423-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
424410

425411
>>> f(), f(), f()
426412
([array(-9.8912135), array(-9.80160951)],
@@ -450,7 +436,6 @@ normal_rv{"(),()->()"}.0 [id A] <RandomGeneratorType> 0
450436
└─ ···
451437
normal_rv{"(),()->()"}.0 [id F] <RandomGeneratorType> 1
452438
└─ ···
453-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
454439

455440
>>> f(), f(), f()
456441
([array(-5.81223492), array(-5.85081162)],
@@ -460,15 +445,15 @@ normal_rv{"(),()->()"}.0 [id F] <RandomGeneratorType> 1
460445
We could have used a single rng.
461446

462447
>>> rng_x = pytensor.shared(np.random.default_rng(seed=123), name="rng_x")
463-
>>> next_rng_x, x = pt.random.normal(loc=0, scale=1, rng=rng).owner.outputs
448+
>>> next_rng_x, x = pt.random.normal(loc=0, scale=1, rng=rng_x).owner.outputs
464449
>>> next_rng_x.name = "next_rng_x"
465450
>>> next_rng_y, y = pt.random.normal(loc=100, scale=1, rng=next_rng_x).owner.outputs
466451
>>> next_rng_y.name = "next_rng_y"
467452
>>>
468-
>>> f = pytensor.function([], [x, y], updates={rng: next_rng_y})
453+
>>> f = pytensor.function([], [x, y], updates={rng_x: next_rng_y})
469454
>>> pytensor.dprint(f, print_type=True) # doctest: +SKIP
470455
normal_rv{"(),()->()"}.1 [id A] <Scalar(float64, shape=())> 0
471-
├─ rng [id B] <RandomGeneratorType>
456+
├─ rng_x [id B] <RandomGeneratorType>
472457
├─ NoneConst{None} [id C] <NoneTypeT>
473458
├─ 0 [id D] <Scalar(int8, shape=())>
474459
└─ 1 [id E] <Scalar(int8, shape=())>
@@ -480,24 +465,23 @@ normal_rv{"(),()->()"}.1 [id F] <Scalar(float64, shape=())> 1
480465
└─ 1 [id E] <Scalar(int8, shape=())>
481466
normal_rv{"(),()->()"}.0 [id F] <RandomGeneratorType> 'next_rng_y' 1
482467
└─ ···
483-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
484468

485469
>>> f(), f()
486-
([array(0.91110389), array(101.4795275)],
487-
[array(0.0908175), array(100.59639646)])
470+
([array(-0.98912135), array(99.63221335)],
471+
[array(1.28792526), array(100.19397442)])
488472

489-
It works, but that graph is slightly unorthodox in Pytensor.
473+
It works, but that graph is slightly unorthodox in PyTensor.
490474

491-
One practical reason is that it is more difficult to define the correct update expression for the shared RNG variable.
475+
One practical reason why, is that it is more difficult to define the correct update expression for the shared RNG variable.
492476

493-
One techincal reason is that it makes rewrites more challenging in cases where RandomVariables could otherwise be manipulated independently.
477+
One techincal reason why, is that it makes rewrites more challenging in cases where RandomVariables could otherwise be manipulated independently.
494478

495479
Creating multiple RNG variables
496480
-------------------------------
497481

498482
RandomStreams generate high quality seeds for multiple variables, following the NumPy best practices https://numpy.org/doc/stable/reference/random/parallel.html#parallel-random-number-generation.
499483

500-
Users who create their own RNGs should follow the same practice!
484+
Users who sidestep RandomStreams, either by creating their own RNGs or relying on RandomVariable's default shared RNGs, should follow the same practice!
501485

502486
Random variables in inner graphs
503487
================================
@@ -629,7 +613,7 @@ RNGs in Scan are only supported via shared variables in non-sequences at the mom
629613
>>> print(err)
630614
Tensor type field must be a TensorType; found <class 'pytensor.tensor.random.type.RandomGeneratorType'>.
631615

632-
In the future, TensorTypes may be allowed as explicit recurring states, rendering the use of updates optional or unnecessary
616+
In the future, RandomGenerator variables may be allowed as explicit recurring states, rendering the internal use of updates optional or unnecessary
633617

634618
OpFromGraph
635619
-----------
@@ -671,7 +655,7 @@ Other backends (and their limitations)
671655
Numba
672656
-----
673657

674-
NumPy random generator can be used with Numba backend.
658+
NumPy random generators can be natively used with the Numba backend.
675659

676660
>>> rng = pytensor.shared(np.random.default_rng(123), name="randomstate_rng")
677661
>>> x = pt.random.normal(rng=rng)
@@ -692,19 +676,18 @@ Inner graphs:
692676
└─ *4-<Scalar(float32, shape=())> [id K] <Scalar(float32, shape=())>
693677
← normal_rv{"(),()->()"}.1 [id G] <Scalar(float64, shape=())>
694678
└─ ···
695-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
696679

697680
>>> print(numba_fn(), numba_fn())
698681
-0.9891213503478509 -0.9891213503478509
699682

700683
JAX
701684
---
702685

703-
JAX uses a different type of PRNG than those of Numpy. This means that the standard shared RNGs cannot be used directly in graphs transpiled to JAX.
686+
JAX uses a different type of PRNG than those of NumPy. This means that the standard shared RNGs cannot be used directly in graphs transpiled to JAX.
704687

705-
Instead a copy of the Shared RNG variable is made, and its bit generator state is given a jax_state entry that is actually used by the JAX random variables.
688+
Instead a copy of the Shared RNG variable is made, and its bit generator state is expanded with a jax_state entry. This is what's actually used by the JAX random variables.
706689

707-
In general, update rules are still respected, but they won't be used on the original shared variable, only the copied one actually used in the transpiled function
690+
In general, update rules are still respected, but they won't update/rely on the original shared variable.
708691

709692
>>> import jax
710693
>>> rng = pytensor.shared(np.random.default_rng(123), name="rng")
@@ -718,7 +701,6 @@ uniform_rv{"(),()->()"}.1 [id A] <Scalar(float64, shape=())> 0
718701
└─ 1.0 [id E] <Scalar(float32, shape=())>
719702
uniform_rv{"(),()->()"}.0 [id A] <RandomGeneratorType> 0
720703
└─ ···
721-
<ipykernel.iostream.OutStream at 0x7fa5d3a475e0>
722704

723705
>>> print(jax_fn(), jax_fn())
724706
[Array(0.07577298, dtype=float64)] [Array(0.09217023, dtype=float64)]

0 commit comments

Comments
 (0)