A functional training loop

JAX · Practical · 15 min

This pattern is the backbone of every JAX training script, including those built on Flax and Optax.

In JAX, training state is data you pass around, not objects you mutate. A training step is a pure function:

new_params, new_opt_state, loss = train_step(params, opt_state, batch)

Inside it you compute jax.value_and_grad(loss_fn)(params, batch), apply an update with jax.tree.map, and return everything new. Decorate it with @jax.jit so the whole step, forward and backward pass plus update, compiles to one executable.

Patterns to follow:

  • Keep parameters in a pytree (dict or registered dataclass); initialise them from split random keys.
  • Thread the RNG key through the step if you use dropout, splitting each step.
  • Iterate in Python over batches and call the jitted step; the Python loop is cheap compared to the compiled step.
  • Keep batch shapes fixed (drop or pad the last batch) to avoid recompiling.

Real projects rarely hand-roll optimizers. Optax provides optax.adam(lr) with an init(params) and update(grads, state, params) API that fits this exact shape, and Flax (its nnx API) or Equinox provide layers. Learning the bare pattern first makes those libraries transparent: they are pytrees plus pure functions.

Watch for host-device syncs in the loop: calling float(loss) every step forces the program to wait for the device. Log every N steps instead.

Example

import jax
import jax.numpy as jnp

def init(key, n_in):
    return {"w": jax.random.normal(key, (n_in,)) * 0.1, "b": jnp.zeros(())}

def loss_fn(params, x, y):
    pred = x @ params["w"] + params["b"]
    return jnp.mean((pred - y) ** 2)

@jax.jit
def train_step(params, x, y, lr=0.1):
    loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
    params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
    return params, loss

key, k_data, k_init = jax.random.split(jax.random.key(0), 3)
x = jax.random.normal(k_data, (256, 3))
y = x @ jnp.array([2.0, -1.0, 0.5]) + 3.0
params = init(k_init, 3)
for step in range(200):
    params, loss = train_step(params, x, y)
print(jnp.round(params["w"], 2), jnp.round(params["b"], 2))  # [ 2. -1.  0.5] 3.0
print(float(loss) < 1e-4)   # True

Remember

  • A train step is a pure function from (params, batch) to (new params, metrics)
  • Jit the whole step; loop over batches in Python
  • Optax and Flax follow the same pure, pytree-based pattern

Watch out for

  • Converting the loss to a Python float every step, which blocks on the device and slows training.

Check yourself

  1. Why does a JAX train step return new parameters instead of updating them in place?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.jax.dev/en/latest/the-training-cookbook.html