Part I · Foundations Week 2 Published harness.py test_training_harness.py Notebook

Flax NNX Modules and Training Loop

Build the minimal training pipeline reused for every SSM experiment in the curriculum: NNX modules, dual-optimizer pattern, nnx.scan for layer stacking, and the SSMParam variable type.

On this page
  1. From Linen to NNX
  2. The sequence model as module
  3. The training loop
  4. Noisy sine prediction
  5. Layer stacking with nnx.scan
  6. The reusable experiment harness
  7. Custom variable type for SSM parameters
  8. Dual optimizer with nnx.DiffState
  9. SSM training recipe
  10. Reflections
  11. Infrastructure investment
  12. What transfers from Week 1

From Linen to NNX

Flax is JAX’s primary neural network library (analogous to PyTorch’s nn module). Flax NNX (Neural Network eXperimental) is the current API as of Flax 0.12 (March 2026). The previous API, called Linen, used a functional style where parameters lived outside the module; NNX uses a more familiar object-oriented style where parameters are Python attributes. All code in this curriculum uses NNX.

AspectLinen (legacy)NNX (current)
InitializationLazy setup()Eager __init__
Parametersself.param('name', init, shape)Normal Python attributes
Mutable stateself.variable('cache', ...)nnx.Variable(...) attribute
Scan layersnn.scan(Module, ...)nnx.vmap plus nnx.scan
Train stateTrainStatennx.Optimizer
The mamba2-jax reference repo in experiments/refs/ already uses NNX — it serves as our style guide for SSM modules.

The most important change for training: nnx.Optimizer requires a wrt= (“with respect to”) argument specifying which variable type to optimize. For example, wrt=SSMParam means “only update variables of type SSMParam; ignore all others.” This enables the dual-optimizer pattern central to SSM training: one optimizer for the SSM’s internal parameters (A\statemat, B\inputmat, Δ\stepsize) at a lower learning rate, and a separate optimizer for the rest of the network at a higher learning rate. [Official] Originally recommended in the S4 paper Gu et al. (2020) ; reproduced across S4D, S5, and Mamba reference code.

The sequence model as module

A minimal Conv1D → MLP sequence model demonstrates the NNX module pattern:

class SimpleSeqModel(nnx.Module):
    def __init__(self, features, kernel_size=3, *, rngs):
        self.conv = nnx.Conv(1, features, kernel_size, rngs=rngs)
        self.dense1 = nnx.Linear(features, features, rngs=rngs)
        self.dense2 = nnx.Linear(features, 1, rngs=rngs)

    def __call__(self, x):  # (B, L, 1) -> (B, L, 1)
        h = nnx.relu(self.conv(x))
        h = nnx.relu(self.dense1(h))
        return self.dense2(h)
Proposition 2.1 (Shape preservation).

For input tensor xRB×L×1x \in \R^{B \times \seqlen \times 1}, SimpleSeqModel returns yRB×L×1y \in \R^{B \times \seqlen \times 1}. SAME padding keeps the sequence length unchanged, matching Flax’s default Conv1D behavior.

Proof.

Conv1D: (B,L,1)(B,L,F)(B, \seqlen, 1) \to (B, \seqlen, F) with SAME padding. Dense1: (B,L,F)(B,L,F)(B, \seqlen, F) \to (B, \seqlen, F). Dense2: (B,L,F)(B,L,1)(B, \seqlen, F) \to (B, \seqlen, 1). Sequence length is preserved at every stage.

The training loop

The NNX training pattern differs fundamentally from PyTorch. In PyTorch, you call separate functions for zeroing gradients, running the forward pass, computing gradients (backpropagation), and updating parameters. In JAX, these are composed into a single function that is compiled and executed as one unit:

StepPyTorchJAX/NNX
Zero gradientsoptimizer.zero_grad()(not needed)
Forward passy = model(x)nnx.value_and_grad(loss_fn)
Backward passloss.backward()returns loss and gradients together
Update paramsoptimizer.step()optimizer.update(model, grads)
Compiletorch.compile(model)@nnx.jit (wraps entire step)
value_and_grad: returns loss and gradients together; JAX traces once into XLA and runs forward+backward as one graph.
@nnx.jit
def train_step(model, optimizer, x, y):
    def loss_fn(model):
        return jnp.mean((model(x) - y) ** 2)
    loss, grads = nnx.value_and_grad(loss_fn)(model)
    optimizer.update(model, grads)  # in-place
    return loss
Remark (API change in Flax 0.11+).

optimizer.update() requires both model and grads as arguments (changed from single-argument in earlier versions). The wrt= filter on the optimizer handles extracting the relevant subset of gradients internally.

Training on the noisy sine prediction task. Left: MSE loss over 300 steps with 20-step moving average. Right: model predictions after training vs. ground truth.
Training on the noisy sine prediction task. Left: MSE loss over 300 steps with 20-step moving average. Right: model predictions after training vs. ground truth.

Noisy sine prediction

The synthetic task validates the training harness before plugging in SSM layers. Each sample is a noisy sine wave with random frequency ωUniform(0.5,2.0)\omega \sim \text{Uniform}(0.5, 2.0). The target is next-step prediction: yt=xt+1y_t = x_{t+1}.

The reusable make_batch helper returns (x,y)(x, y) and appears throughout the curriculum:

WeekTaskProtocol
2Noisy sinemake_noisy_sine_batch
7Selective copyingmake_copy_batch
11MQARmake_mqar_batch

Layer stacking with nnx.scan

lax.scan (Week 1)nnx.scan (this chapter)
Iterates overSequence time stepsLayer depth
CarryState ht\statevec_tHidden activation h\bm{h}
BodySSM step functionResidual block
ParametersSame at every stepDifferent per layer

The NNX pattern splits initialization from execution:

  1. nnx.vmap over __init__: creates NN independent parameter sets (one per layer).
  2. nnx.scan over __call__: applies the same function sequentially with the carry pattern, using different parameters at each step.

The split_rngs decorator splits the PRNG key so each layer receives a unique random seed for initialization (vmap) or execution (scan):

# Init: vmap creates separate params per layer
@nnx.split_rngs(splits=num_layers)
@nnx.vmap(in_axes=(0,), out_axes=0)
def create_blocks(rngs):
    return ResidualBlock(features, rngs=rngs)

# Forward: scan applies blocks sequentially
@nnx.split_rngs(splits=num_layers)
@nnx.scan(in_axes=(nnx.Carry, 0), out_axes=nnx.Carry)
def forward(h, block):
    return block(h)
Remark (Residual connections improve gradient transport).

For a stack of residual blocks fi(x)=x+gi(x)f_i(x) = x + g_i(x) applied via nnx.scan, each layer Jacobian takes the form I+gi/xI + \partial g_i / \partial x rather than just gi/x\partial g_i / \partial x. This often makes gradients easier to propagate through deep stacks, but it does not guarantee nonzero L/θi\partial L / \partial \theta_i for every layer on every input. Zero loss gradients, saturated activations, or parameter symmetries can still produce zero parameter gradients.

Final MSE loss vs. number of scan-stacked residual blocks on the noisy sine task (200 training steps). Deeper models converge to lower loss despite the short training budget.
Final MSE loss vs. number of scan-stacked residual blocks on the noisy sine task (200 training steps). Deeper models converge to lower loss despite the short training budget.

The reusable experiment harness

Custom variable type for SSM parameters

SSM parameters (A, B, Δ\stepsize) need different optimizer treatment than backbone parameters (Dense, LayerNorm). The NNX solution: subclass nnx.Variable:

class SSMParam(nnx.Variable):
    """SSM-specific params: low LR, zero weight decay."""
    pass

Then create two optimizers filtered by variable type:

ssm_opt = nnx.Optimizer(model, ssm_tx, wrt=SSMParam)
backbone_opt = nnx.Optimizer(model, backbone_tx, wrt=nnx.Param)

The motivation for two optimizers is that the SSM inner-parameter landscape is narrow — eigenvalues must stay inside a thin band near the stability boundary — while the backbone’s MLP/linear weights tolerate much larger updates. A single learning rate compromises between exploration of the backbone and precision on the SSM; two optimizers let each subspace use its own tempo.

Example 2.2 (Dual-optimizer update on a two-parameter stand-in).

A toy model with ASSMParam\statemat \in \text{SSMParam} and CParam\outputmat \in \text{Param}. Both receive unit gradients A=C=1.0\nabla_{\statemat} = \nabla_{\outputmat} = 1.0 at every step. With SSM LR 10310^{-3} and backbone LR 8×1048 \times 10^{-4} (vanilla SGD for clarity), one step moves

ΔA=103,ΔC=8×104.\Delta\statemat = -10^{-3}, \qquad \Delta\outputmat = -8 \times 10^{-4}.

Over 1,000 steps A\statemat travels 1.01.0 units while C\outputmat travels 0.80.8 — deliberately close in magnitude, with the SSM slightly faster to accelerate eigenvalue search. Pointing a single optimizer at both produces either an SSM that wanders outside the stable cone or a backbone that crawls; separating them resolves the conflict.

Dual optimizer with nnx.DiffState

When computing gradients, we need to tell JAX which parameters to differentiate with respect to. nnx.DiffState(0, SSMParam) means: “in the first function argument (the model), compute gradients only for variables of type SSMParam.” This produces a gradient tree that matches the shape expected by the wrt=SSMParam optimizer.

In Flax 0.12.6, passing full gradients to a wrt-filtered optimizer causes a tree structure mismatch (the optimizer expects only SSMParam leaves, but receives all leaves). The DiffState pattern avoids this:

loss, bb_grads = nnx.value_and_grad(
    loss_fn, argnums=nnx.DiffState(0, nnx.Param)
)(model)
ssm_grads = nnx.grad(
    loss_fn, argnums=nnx.DiffState(0, SSMParam)
)(model)
ssm_opt.update(model, ssm_grads)
backbone_opt.update(model, bb_grads)

SSM training recipe

SSM training requires careful hyperparameter choices. The learning rate (LR) controls how large each gradient update step is. Weight decay is a regularization technique that shrinks parameters toward zero each step, preventing overfitting. SSM parameters (A, B, Δ\stepsize) should not have weight decay because their values encode the system dynamics, not learned features. The warmup phase starts training with a very small LR that linearly increases to the peak, avoiding early instability when the model is randomly initialized.

SSM paramsBackbone
OptimizerAdamAdamW
Peak LR10310^{-3}8×1048 \times 10^{-4}
Weight decay00.01
Schedule10% warmup + cosine10% warmup + cosine
Grad clip1.0 global norm1.0 global norm
Precisionfp32 (required)fp32 (bf16 optional)
Comparison of single optimizer (fixed LR) vs. the full harness (warmup + cosine decay, gradient clipping). On the noisy sine task, the warmup avoids early instability and cosine decay provides smooth convergence.
Comparison of single optimizer (fixed LR) vs. the full harness (warmup + cosine decay, gradient clipping). On the noisy sine task, the warmup avoids early instability and cosine decay provides smooth convergence.

Reflections

Infrastructure investment

This chapter builds plumbing, not research. But every subsequent week plugs into the same harness:

  • Week 4: S4 layer replaces self.conv in SimpleSeqModel. SSMParam wraps HiPPO parameters.
  • Week 6: S5 diagonal SSM uses lax.associative_scan (Week 1) inside the nnx.scan-stacked layers from this chapter.
  • Week 7: Mamba’s selective scan uses the same dual-optimizer recipe but with input-dependent B\inputmat, C\outputmat, Δ\stepsize.
  • Weeks 11–12: Different synthetic tasks (MQAR, selective copying) follow the make_batch protocol.
  • Week 18: Lyapunov analysis reuses the harness to train SSMs, then analyzes the trained Aˉt\discA_t matrices.

What transfers from Week 1

  • lax.scan handles SSM time steps, while nnx.scan handles layer depth. They solve different problems with the same structural pattern.
  • Automatic differentiation through scans (jax.grad) works identically for both kinds of scan — the same gradient machinery trains both the SSM parameters and the backbone.