Randomness

Any layer that does stochastic sampling (a Gaussian sample, a noisy neuron, a stochastic dropout mask, a Bernoulli gate) needs an RNG key. Where you put that key matters for gradient correctness. This page sets out the rule and explains the two reasons behind it.

The rule

For a stochastic StatefulModule, the RNG key lives in the carry state — returned from initialize_state(), threaded through __call__(), refreshed by reset_state() if appropriate. Specifically: keep one key per env, of shape [B], advanced independently per env on each forward step.

Do not keep the key as a class-level nnx.Variable (or nnx.RngKey) that you read and advance inside __call__(). That looks natural — it’s how stateful modules typically work in non-RL contexts — but it produces silently wrong gradients in nnx-ppo.

Two reasons not to use a class-level RNG

The rollout / loss-replay split

Each training iteration runs the network twice:

  • A rollout pass to drive the environment and collect data.

  • A loss-replay pass that recomputes activations on the recorded observations so the PPO loss can be differentiated.

The library is built on a guarantee that the second pass reproduces exactly the activations of the first (see The rollout_extras channel for the write rule that enforces this). Stored rollout_extras (in particular the raw actions captured by action samplers) feed the LOSS_REPLAY pass; deterministic forward operations behave identically.

If you advance an nnx.Variable RNG inside __call__(), the rollout pass will leave that variable in its post-rollout state. The loss replay then starts from the post-rollout RNG and produces different samples than the rollout did. PPO’s importance ratios are computed against the rollout activations, so the gradient now points in a meaningless direction.

The minibatching caveat

You might think: “fine, I’ll snapshot the RNG at the start of the rollout and restore it before the loss replay.” That fixes the split, but it does not fix a deeper problem.

The rollout calls the network once per timestep on the full [n_envs, ...] batch. The loss replay, by contrast, runs one minibatch at a time with batch size n_envs / n_minibatches. Even with the same starting key, jax.random.split called on a single-stream key advances differently when the per-call batch shape changes — the rollout and the replay produce different sequences of per-call subkeys, so they disagree on which random number each env sees.

There is no single-stream RNG carried on the module that can reconcile both call patterns. The fix has to make the per-env RNG state structurally independent of how the batch is sliced.

The pattern: one RNG per env, in carry state

Carry an RNG per env as part of the module’s carry state. Because the per-env carry is sliced by JAX along with every other batched quantity (rewards, dones, obs, the network’s other carry state), the per-env RNG stays in sync with the env it belongs to across rollout, minibatching, and replay alike. Splitting that RNG inside __call__() is a pure local operation on the per-env carry — it doesn’t depend on the global batch shape.

VariationalBottleneck is the canonical worked example:

class VariationalBottleneck(StatefulModule):
    def __init__(self, latent_size, rng, kl_weight, min_std=1e-6):
        self.rng = rng                       # nnx.Rngs — used only at init
        ...

    def initialize_state(self, batch_size):
        # Build the per-env carry: B independent keys, derived once
        # at construction time from the module's class-level RNG.
        return jax.random.split(self.rng(), batch_size)

    def __call__(self, key, x, rollout_extras=None):
        # `key` has shape [B]. Each env's key is split locally; the
        # new keys become the next carry. No class-level RNG is read
        # inside __call__.
        eps = jax.vmap(lambda k: jax.random.normal(k, (self.latent_size,)))(key)
        ...
        next_key, _ = jax.vmap(jax.random.split, out_axes=1)(key)
        return StatefulModuleOutput(next_state=next_key, output=z, ...)

The class-level nnx.Rngs is consulted once, in initialize_state(), to seed the per-env carry. After that, the forward pass is pure on its inputs — including the carry RNG keys — and the rollout/replay/minibatching machinery handles the rest for free.

If your env-reset semantics call for a fresh RNG on reset, implement reset_state() to re-split the class-level RNG. If you want the key chain to survive resets (which is what VariationalBottleneck does), have reset_state() return prev_state unchanged. Either is fine — what matters is that within an episode, the per-env carry advances deterministically from its initial value.

When you can ignore this

If your module is not stochastic — Dense, LSTM, Concat, anything that does no sampling inside __call__() — there is no RNG to manage and nothing to do.

If your module’s only randomness is in parameter initialisation (read once at construction time), pass an nnx.Rngs keychain to __init__ and use it there. That falls outside the rule: init isn’t called in the forward path.

The ActionSampler family is the one place where module-internal RNG advancement is OK — because the loss-replay pass receives the stored raw action via rollout_extras and uses it instead of the freshly-sampled value. Even there, the RNG is advanced consistently across contexts so downstream stochastic layers stay in lockstep.

Why not RNG-in-state for action samplers

You might wonder: can the action sampler use the per-env-RNG pattern above instead of stashing raw_action in rollout_extras? No — and the reason is a subtle but load-bearing one.

After the gradient phase, the network’s parameters have changed. Even with the same per-env RNG, the sampler under updated weights samples a different action than during rollout (same noise, new mean/std). But PPO needs the log-likelihood of the action that was actually taken under the new policy, not a hypothetical new action.

For modules whose sample is consumed as a forward activation through reparameterised gradients (i.e. VariationalBottleneck), “same noise, new mean/std, new sample” is the right behaviour — that’s how reparameterised gradients work. For action samplers in PPO it isn’t, because the sample needs to be locked to the rollout value. Hence rollout_extras.