Delay and Normalizer placement
Both Delay and
Normalizer are ordinary
StatefulModule s — they can sit
anywhere a layer fits, not just at the top of the network.
Delay
0)
is a fixed-length circular buffer. The output at time t is the
input from time t - k_steps. Before the buffer has filled
(t < k_steps), the output is initial_value. On
reset_state() the buffer is zeroed and the write pointer
restarts.
The shape of the buffer is inferred from the unbatched sample_input
PyTree — pass a single example obs / activation so Delay
knows the leaf shapes and dtypes.
Common placements:
Top-level observation delay. Wrap the entire network behind a
Delay to give the agent stale observations:
sample_obs = jax.jit(env.reset)(jax.random.key(0)).obs
delayed_net = Sequential([Delay(sample_obs, k_steps=5), inner])
Inside a stack. Delay works just like any other layer in
Sequential:
Sequential([encoder, Delay(latent_shape, k_steps=3), decoder])
As a graph connection transform. When a graph connection should
carry a transformed signal with extra delay, use a small
Sequential for the transform:
graph.connect(
"src", "dst",
transform=Sequential([Dense(H, H, rngs), Delay(jp.zeros(H), k_steps=2)]),
)
If you only need plain integer-step delay, use the delay= kwarg
on PopulationGraph.connect() instead — it shares a single
buffer per source population. The Delay module is the right tool
when the delay is per-connection and the connection has a custom
transform.
Recurrent self-loop. With PopulationGraph you do not
need Delay for recurrence — graph.connect(k, k, delay=1) is
enough. With containers, build the recurrence by hand using
Delay plus a self-referential Sequential.
Normalizer
Normalizer(shape) maintains a
running mean and variance (via batched Welford) over the inputs it
sees, and standardises its input using those running stats. The
shape argument is either an int / tuple (for a flat
tensor) or a pytree of shapes (for structured observations). Stats
are stored as nnx.Variable s; they do not receive
gradients.
The forward pass standardises every call. The running stats only
update when update_statistics() is called explicitly by the PPO
training loop (after the gradient phase), with the rollout’s
rollout_extras history — see The rollout_extras channel.
Top-level placement (typical)
Putting the normalizer at the top of the network is the simplest case: every observation the network sees first passes through the normalizer, and the stats it tracks are the raw observations.
nets = Sequential([
Normalizer(env.observation_size),
PPOAdapter(
action=Sequential([actor, sampler]),
value=critic,
),
])
This is what
make_mlp_actor_critic() does
internally.
Embedded placement
You can also put a Normalizer deeper in the network — behind
a Delay, after an encoder, inside a population in a
PopulationGraph. The stats it tracks are the activations it
actually sees at that point in the network, not the raw
observations.
Sequential([Delay(sample_obs, k=5), Normalizer(obs_shape), actor])
The normalizer here tracks delayed observations, not fresh ones.
The update_statistics model
How can an embedded normalizer see the right inputs? Because on every
forward pass, Normalizer emits the input it just standardised
as its rollout_extras slot. The rollout scan stacks these over T
into Transition.rollout_extras, with structure matching the
network’s state tree.
After the gradient phase of each PPO step,
ppo_step() calls
network.update_statistics(rollout.rollout_extras). The containers
route per child the same way they routed state; each
Normalizer receives a [T, B, *feat] slice of exactly the
inputs it saw during rollout, and folds the batch into its running
mean / M2 / counter via one batched Welford merge. No replay forward
pass is required.
This is why the “no writes to NNX variables that affect the forward
output in __call__, ever” rule from The rollout_extras channel is load-bearing:
it keeps the rollout / loss-replay agreement, and lets statistics flow
through the explicit update_statistics() channel rather than as
hidden side effects of a forward pass.
Pytree shapes
For dict-structured observations, pass a matching dict of shapes:
Normalizer({"proprio": 12, "goal": 4})
The forward pass returns a dict with each leaf standardised independently. Useful when each obs key has a wildly different scale.
Stats lifecycle
Initialisation: stats start at
mean=0,M2=0,counter=0. Before any update, the normalizer uses a default std of 10 (so it effectively passes input through, scaled down to be order-1).Updates are accumulated across the entire training run (no windowing). To “reset” stats you would construct a new
Normalizerinstance.Stats are persisted by the checkpointing path along with everything else (they are NNX variables).