Custom networks 1: composing with containers
The make_mlp_actor_critic() factory
from Quickstart is enough when your network is one MLP for
the actor, one MLP for the critic, and an optional observation
normalizer in front. Beyond that — encoder-decoder actors, shared
trunks, structured observations — you assemble the network by hand
from container modules. This tutorial walks through that.
Mental model
Every layer in nnx-ppo implements StatefulModule.
A StatefulModule is anything callable as
module(state, x, rollout_extras=None) -> StatefulModuleOutput. Containers
themselves are StatefulModule s — they just compose children.
Two kinds of state are tracked separately:
NNX-tracked state (params, RNG streams, running stats) lives on the module instance and is updated by NNX. It is not reset across episodes.
Carry state is the
stateargument passed in and thenext_statefield ofStatefulModuleOutput. It is what gets reset when the env resets (LSTM hidden state,Delaybuffer, RNG carry for variational layers, …).
The containers in nnx_ppo.networks.containers thread both kinds
of state to their children. You compose them; you do not write the
plumbing.
A hand-built MLP actor-critic
To get a feel for the moving parts, rebuild what the quickstart factory produces:
from flax import nnx
from nnx_ppo.networks.adapter import PPOAdapter
from nnx_ppo.networks.containers import Sequential
from nnx_ppo.networks.feedforward import Dense
from nnx_ppo.networks.normalizer import Normalizer
from nnx_ppo.networks.sampling_layers import NormalTanhSampler
rngs = nnx.Rngs(0)
obs_size = 8 # example
action_size = 2
actor = Sequential([
Dense(obs_size, 64, rngs, activation=nnx.swish),
Dense(64, 64, rngs, activation=nnx.swish),
Dense(64, 2*action_size, rngs),
NormalTanhSampler(rngs, entropy_weight=1e-2, min_std=0.1),
])
critic = Sequential([
Dense(obs_size, 64, rngs, activation=nnx.swish),
Dense(64, 64, rngs, activation=nnx.swish),
Dense(64, 1, rngs),
])
nets = Sequential([
Normalizer(obs_size),
PPOAdapter(
action=actor,
value=critic,
),
])
This is exactly what make_mlp_actor_critic()
produces. The PPOAdapter is a regular layer in the
Sequential; its two ports (action= and value=) both
receive the same input and run their own chains independently. Here,
we have placed a Normalizer in sequence with the PPOAdapter,
so that the normalization becomes shared between the actor and critic.
In principle, we could also have given the actor and critic their own
normalizers, but that would have lead to doubling the total compute
and memory required by normalization.
Finally, note the actor’s output size: 2 * action_size. The action sampler
expects [mean | log_std] concatenated along the last axis. For a
Gaussian-tanh policy the actor produces both; the sampler splits and
samples.
The essential containers
The worked examples below use a handful of containers. Each is a
StatefulModule that composes others. Here are the ones you
will reach for first; the rest live in
Containers and Pytree utilities.
SequentialChain layers.
Sequential([a, b, c])runsa → b → c.ConcatPer-stream encoder for structured (dict) observations. Runs each named sub-module on its own slice of the input dict and concatenates the outputs along the last axis.
FlattenerReshape a pytree to a single
(B, D)tensor. Withpreserve_levels=Nthe topNlevels of dict/list/tuple structure are preserved.FilterDeclarative pytree extraction / projection. Takes a dict
{output_key: spec}where each spec is a string (top-level key), a tuple of strings/ints (nested path), or a callable applied to the full input. Use it to ablate obs streams or give the critic privileged info.MapPer-key dispatch: dict input, dict output, with a different sub-module per key.
Map({k: f for k in keys})applies eachfto the upstream’s same-named entry.
For Parallel, Splitter, Merge, Scale, and the full per-container contracts (carry state shapes, construction forms, etc.) see the reference pages.
The adapter
PPOAdapter is the canonical leaf
that turns a network’s forward output into a
PPONetworkOutput. It is a regular
StatefulModule and lives inside a Sequential like
any other layer.
It has two ports — action and value — and both receive the
same upstream input. Each port runs its own chain. The action port
must emit a tree of sampler dicts ({"action", "log_likelihood"}
leaves, the standard
ActionSampler output);
the value port emits whatever value tensor your critic produces.
Both the action and value ports also accept (nested)
dictionaries of tensors — see the multi-head example below.
See PPOAdapter reference for the full contract.
Multi-head actor / critic
If your env produces multi-objective rewards or your policy has multiple independent actions (one per body part, say), declare more than one action sampler or value head:
from nnx_ppo.networks.utils import Filter, Map
# Trunk emits {"action_arm": ..., "action_leg": ..., "value_arm": ..., "value_leg": ...}.
nets = Sequential([
shared_trunk_emitting_named_heads,
PPOAdapter(
action=Sequential([
Filter({"arm": "action_arm", "leg": "action_leg"}), # rename
Map({ # per-key dispatch
"arm": NormalTanhSampler(rngs, entropy_weight=1e-2),
"leg": NormalTanhSampler(rngs, entropy_weight=1e-2),
}),
]),
value=Filter({"arm": "value_arm", "leg": "value_leg"}),
),
])
With multiple heads PPONetworkOutput.actions becomes
{"arm": ..., "leg": ...} and value_estimates becomes
{"arm": ..., "leg": ...}. The PPO loss accepts either shape — it
computes GAE per reward key independently.
Privileged critic via per-port Filter
Sometimes the critic should see strictly more than the actor — full
goal information, ground-truth distances, anything the actor must
infer at deployment. Because the PPOAdapter’s two ports are
already independent chains operating on the same upstream input, each
port can just Filter the obs to the subset it’s allowed to
use:
from nnx_ppo.networks.utils import Filter, Flattener
actor_chain = Sequential([
Filter({ # actor sees proprio only
"proprio": ("proprio",),
}),
Flattener(),
actor_mlp,
sampler,
])
critic_chain = Sequential([
Filter({ # critic sees everything
"proprio": ("proprio",),
"goal": ("goal",),
"privileged_distance": ("goal", "dist"),
}),
Flattener(),
critic_mlp,
])
nets = Sequential([
Normalizer(obs_size),
PPOAdapter(action=actor_chain, value=critic_chain),
])
The two ports are completely independent: different obs slices, different parameter sets, different depths. Each port owns its full chain from upstream input through to its respective output shape.
What’s next
Containers stop being expressive enough when your topology has
multiple connections feeding the same node, recurrent loops, or
per-connection delays. Custom networks 2: graph networks introduces
PopulationGraph for those cases.
If you need a layer the library does not provide, Custom networks 3: writing your own StatefulModule
walks through implementing StatefulModule yourself.