the jax path · 0/12
start the path

the jax path · Randomness · lesson 02 of 3

Reuse is silent

The mistakes museum collects programs that stop, each exhibit pairing a snippet with the error text it printed. Reusing a key stops nothing. The run finishes, the loss goes down, and two things that were supposed to be independent are the same array.

the goal Recognize key reuse from its symptoms rather than from an error, measure what it costs an estimator, and turn the silent failure into a raised exception before it reaches anyone else.

mastery work · this chapter0/3
  1. go →
  2. go →
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
§ 01

The failure that prints nothing

Initialize two weight matrices from one key and they come back identical. Not similar, not correlated: the same nine numbers, twice, because a key plus a shape determines the bits and nothing else about the two calls differed.

No check fires. The shapes are right, the dtypes are right, the values are plausible draws from a normal, and a model built on them will train. It started from a smaller family of initializations than the code claims, and no output says so.

Which is why this one has no exhibit next door. The museum's cases, inplace-assign and tracer-bool among them, all end in a message you can paste into a search box. There is no message to quote here, so the rest of this lesson is about the symptoms you can measure instead.

run it (verified, jax 0.4.38 CPU): two layers, one key. jax 0.5.0 defaults change the numbers and not the equality
import jax
import jax.numpy as jnp

key = jax.random.key(0)
w1 = jax.random.normal(key, (3, 3))
w2 = jax.random.normal(key, (3, 3))
print(bool(jnp.all(w1 == w2)))
print(w1)

# True
# [[-0.3721109   0.26423115 -0.18252768]
#  [-0.7368197   0.44973662 -0.1521442 ]
#  [-0.67135346 -0.5908641   0.73168886]]
§ 02

Not correlated, identical

Correlated is the usual word for what reuse does, and it undersells the damage. Draw ten thousand normals from a key, draw ten thousand more from the same key, and the correlation comes back 1.0 to fourteen decimal places while the largest elementwise difference is exactly 0.0. The second draw is not a correlated sample of the first. It is the first.

Split the key and the same measurement lands where independence lands. The two children give -0.0055 over ten thousand samples, and a parent's draw measured against one of its children gives -0.0033. Both are the size of sampling noise at n = 10000, and neither is evidence of anything except that the keys were different.

So the test for reuse is not statistical. It is equality, and it is cheap: assert that no two draws you meant to be independent are elementwise equal.

run it (verified, jax 0.4.38 CPU, numpy 2.2.6): correlation on 10000 samples, reused against split
import jax
import jax.numpy as jnp
import numpy as np

key = jax.random.key(0)
k1, k2 = jax.random.split(key)
x = jax.random.normal(key, (10000,))
y = jax.random.normal(key, (10000,))
u = jax.random.normal(k1, (10000,))
v = jax.random.normal(k2, (10000,))
corr = lambda a, b: float(np.corrcoef(np.asarray(a), np.asarray(b))[0, 1])
print(corr(x, y), float(jnp.max(jnp.abs(x - y))))
print(corr(u, v))
print(corr(x, u))

# 0.9999999999999998 0.0
# -0.0054627058855592735
# -0.0033143140158316963
§ 03

The estimator stops converging

Averaging is where reuse turns into a number somebody eventually notices. Eight independent draws of 2048 normals, averaged elementwise, give a mean array with standard deviation 0.352, which is 1 over the square root of 8, or 0.3536, as it should be. Eight draws from one key, averaged the same way, give 0.978.

Nothing got slower and nothing warned. The estimator simply stopped converging at the rate the code was written to assume, which is the shape this bug takes in a Monte Carlo estimate, a dropout ensemble, an augmentation pipeline, or a bootstrap.

If you only remember one diagnostic from this lesson, remember this one. Averaging N samples should shrink the spread by a factor near the square root of N. When it does not, look at the keys before you look at the model.

run it (verified, jax 0.4.38 CPU): the standard deviation of a mean over eight draws, split against reused
import jax
import jax.numpy as jnp

key = jax.random.key(0)
keys = jax.random.split(key, 8)
independent = jax.vmap(lambda k: jax.random.normal(k, (2048,)))(keys).mean(0)
reused = jnp.stack([jax.random.normal(key, (2048,)) for _ in range(8)]).mean(0)
print(float(independent.std()), float(reused.std()), float(1 / jnp.sqrt(8.0)))

# 0.3524690568447113 0.9781718850135803 0.3535533845424652
§ 04

The loop that forgets to advance

The idiom in the jax.random docstring is one line long and every character of it carries weight: key, subkey = jax.random.split(key). The left-hand key is a rebinding, and dropping it turns the loop into three copies of one step.

The stale loop below draws the integers 8, 45, 78, 7 three times over. It calls split on every iteration, so the code looks like it is doing the right thing; what it never does is move the name forward, so the same parent produces the same child each time round.

A key is a value like any other, which means Python will not stop you from reading a stale one. The discipline is a naming discipline: once a key has been split, the parent name should point at the new key, and a key you have already sampled from should never appear again.

verbatim, the basic-usage block of the jax.random module docstring as printed by the local jax 0.4.38 install (its trailing doctest directive trimmed), then a run on this machine (jax 0.4.38 CPU)
>>> seed = 1701
>>> num_steps = 100
>>> key = jax.random.key(seed)
>>> for i in range(num_steps):
...   key, subkey = jax.random.split(key)
...   params = compiled_update(subkey, params, next(batches))

import jax
import jax.numpy as jnp

key = jax.random.key(0)

k = key
stale = []
for _ in range(3):
    stale.append(jax.random.randint(jax.random.split(k)[1], (4,), 0, 100))

k = key
fresh = []
for _ in range(3):
    k, sub = jax.random.split(k)
    fresh.append(jax.random.randint(sub, (4,), 0, 100))

print(jnp.stack(stale))
print(jnp.stack(fresh))

# [[ 8 45 78  7]
#  [ 8 45 78  7]
#  [ 8 45 78  7]]
# [[ 8 45 78  7]
#  [81 73 74 37]
#  [43 35 56 43]]
§ 05

Make it raise

JAX ships a checker for exactly this failure, switched off by default. Wrap the code in jax.debug_key_reuse(True) and a consumed key becomes a KeyReuseError, with two messages depending on where the reuse happened: In pjit, argument 0 is already consumed. when both draws are inside one jitted function, and Previously-consumed key passed to jit-compiled function at index 0 when a key crosses the boundary twice.

Repeating a draw on purpose is still allowed, as long as you say so. jax.random.clone returns a key the checker treats as fresh, and the values that come out match the original exactly, which is usually the reason you wanted it: replaying the same noise for a comparison.

The checker has one blind spot, and it is the sharpest argument in the arc for typed keys. It tracks key<fry> values, so a program built on jax.random.PRNGKey sails through: two draws from the same uint32 key under the checker return identical arrays and raise nothing. The module's own documentation calls the checker experimental and says that in the future we will likely enable it by default, which is a good reason to run it now and find out what it says about your code.

run it (verified, jax 0.4.38 CPU): both KeyReuseError messages, the deliberate replay, and the legacy-key blind spot
import jax
import jax.numpy as jnp

def init(key):
    return jax.random.normal(key, (3, 3)), jax.random.normal(key, (3,))

with jax.debug_key_reuse(True):
    try:
        jax.jit(init)(jax.random.key(0))
    except jax.errors.KeyReuseError as err:
        print(err)
    key = jax.random.key(0)
    a = jax.random.normal(key, (2,))
    try:
        jax.random.normal(key, (2,))
    except jax.errors.KeyReuseError as err:
        print(str(err).splitlines()[0])
    print(bool(jnp.all(a == jax.random.normal(jax.random.clone(key), (2,)))))

    raw = jax.random.PRNGKey(0)
    print(bool(jnp.all(jax.random.normal(raw, (2,)) == jax.random.normal(raw, (2,)))))

# In pjit, argument 0 is already consumed.
# See https://jax.readthedocs.io/en/latest/errors.html#jax.errors.KeyReuseError
# Previously-consumed key passed to jit-compiled function at index 0
# True
# True
before you move on

Check yourself

01 Two calls to jax.random.normal with one key produced arrays whose correlation is 1.0. What is the real relationship between them?

They are the same array. The largest elementwise difference is exactly 0.0, because a key plus a shape determines the bits, so the second call recomputed the first. Independent draws from split keys measure around -0.005 at ten thousand samples.

02 You average eight sampled estimates and the standard deviation of the mean barely falls. What do you check first?

The keys. Averaging eight independent draws of 2048 normals gives 0.352, close to 1 over the square root of 8; averaging eight draws from one key gives 0.978, because the average of one sample with itself is that sample.

03 Why does the reuse checker stay quiet about a program built on jax.random.PRNGKey?

It tracks typed key<fry> values, and a legacy key is a plain uint32 array with no consumption to track. Under jax.debug_key_reuse(True) two draws from one PRNGKey return identical arrays and raise nothing at all.

assigned

Readings