Chapter 6: Managing State in JAX

Managing state in JAX with functional patterns

At its core, JAX is about pure functions plus function transformations like jit, grad and vmap. But real computations are full of "state": model parameters keep getting updated during training, optimizers have to remember momentum, loops carry intermediate results forward, and random number generators have to advance their internal state. These needs naturally clash with a pure-function style. This chapter covers JAX's answer: treat state as ordinary data, pass it into functions explicitly and get it back out as a return value, and use PyTrees to organize complex state.

Learning goals

  • Explain the two conditions for a pure function (determinism, no side effects), and why jit / grad / vmap depend on them
  • Recognize the typical problems mutable state causes in JAX: code that runs only once during tracing, stale results, wrong gradients, and side effects that don't happen per example
  • Get comfortable with the "explicit state passing" pattern: new_state, out = f(state, inputs)
  • Organize PyTree state with dict / list / tuple / NamedTuple / dataclass, and manipulate it with the jax.tree.* tools
  • Implement stateful computations such as a counter, SGD, momentum SGD and Adam, and combine them with jit, grad, vmap and lax.scan
  • See how ecosystem libraries like Optax and Flax wrap state on top of this foundation

Sections

SectionContent
6.1Functional purity and side effects
6.2The challenge of state in functional code
6.3Pattern: explicit state passing
6.4Using PyTrees for structured state
6.5Example: a stateful counter
6.6Example: simple optimizer state
6.7Combining state management with transformations
6.8Practice: implementing stateful functions
QuizChapter 6 quiz

Original course page: https://apxml.com/courses/getting-started-with-jax/chapter-6-managing-state-in-jax

Environment: Python 3.14, JAX 0.12.0.1, CPU. All conclusions here are based on what actually ran in this environment.

How to read this: the explanations reorganize the course's key points, and all code has actually been run. Problematic patterns sit in ❌ cells, each followed right away by the ✅ recommended version; points that need extra care are marked "⚠️ Note", and the "Version notes" section at the end of the chapter collects them.

import sys
import warnings
from functools import partial
from typing import NamedTuple
from dataclasses import dataclass, field

import jax
import jax.numpy as jnp
from jax import lax

print("Python", sys.version.split()[0])
print("JAX   ", jax.__version__)
print("Devices", jax.devices())
Python 3.14.7
JAX    0.12.0.1
Devices [CpuDevice(id=0)]

6.1 Functional purity and side effects

JAX transformations such as jit, grad, vmap and pmap want the functions they transform to be pure functions as far as possible. A pure function meets two conditions:

  1. Deterministic: the same inputs always produce the same outputs.
  2. No side effects: apart from computing its return value, the function has no observable effect on the outside world. Specifically, it does not:
    • modify variables outside the function's local scope (global variables, for example);
    • modify its arguments in place (for example, append to a list that was passed in);
    • do I/O (printing, reading or writing files, network requests);
    • interact with external systems in any other implicit way.

Let's look at a few plain Python examples to tell pure from impure.

# Pure function: the output depends only on the inputs, and nothing outside is affected
def pure_add(a, b):
    return a + b

# Impure: printing is an I/O side effect
def impure_add_and_print(a, b):
    result = a + b
    print(f"The result is {result}")   # side effect
    return result

# Impure: modifies a global variable
call_count = 0
def impure_increment_counter(x):
    global call_count
    call_count += 1             # side effect: modifies global state
    return x + 1

# Impure: modifies an input argument in place
def impure_append_to_list(data_list, value):
    data_list.append(value)     # side effect: modifies the caller's list
    return data_list

print(pure_add(2, 3))
impure_add_and_print(2, 3)
impure_increment_counter(1); impure_increment_counter(1)
print("call_count =", call_count)
lst = [1, 2]
impure_append_to_list(lst, 3)
print("The caller's list was changed:", lst)
5
The result is 5
call_count = 2
The caller's list was changed: [1, 2, 3]

Why does purity matter so much to JAX?

  1. Tracing: the first time jax.jit is called, it doesn't run your Python code on concrete numbers. It runs the function once with abstract "traced values" (tracers), records the array operations inside as an intermediate representation, the Jaxpr, and hands that to XLA to compile. Side effects like printing or modifying a global variable happen only once, during tracing. Later calls that hit the cache run the compiled program directly, and the Python-level side effects never show up again. That's why a print inside a jitted function usually prints only once.
  2. Caching and optimization: JAX caches compiled results by input shape, dtype and so on, on the assumption that "the output is determined only by the inputs". If a function depends on global state, the cached program may use a stale value.
  3. Automatic differentiation: grad needs a clear "inputs → outputs" data flow. Side effects and in-place modification scramble the computation structure the chain rule relies on.
  4. vmap / pmap: the mapped function is also traced only once; there are no separate Python "instances" each running on their own. A modification to shared state inside the function happens only once, and it modifies the tracer that stands for the whole batch, not once per example. If you want each example (or device) to have its own state, you have to pass the state explicitly as an input and output with a batch dimension (demonstrated in 6.2).

The state management problem

Training a model means updating weights, keeping momentum and counting steps. In ordinary Python or object-oriented code, this is usually written as "modify an object's attributes", like the counter class below. That collides head-on with purity. JAX's answer is explicit state passing: pass the current state in as an argument, and return the new state as part of the output (see 6.3).

# A typical object-oriented stateful style: the state hides inside the object, and count() modifies self.n every time
class Counter:
    def __init__(self):
        self.n = 0

    def count(self):
        self.n += 1
        return self.n

    def reset(self):
        self.n = 0

my_counter = Counter()
print(my_counter.count())   # 1
print(my_counter.count())   # 2  (same call, different result: not a pure function)
1
2

🔍 Extra: the "runs only once during tracing" trap when modifying/reading globals inside jit

🟢 Must-read

We said earlier that "side effects happen only during tracing". Let's actually run the three most common traps:

  • Writing a global variable: this happens only during tracing, and later calls don't change it again. Only when the input shape or dtype changes and triggers a retrace does it "occasionally" run again, so the count looks completely random.
  • Reading a global variable: the value read during tracing is baked into the compiled result as a constant. If you change the global later, the cached version doesn't notice; only when a new shape triggers recompilation does it pick up the new value. The result is that the same function gives "a mix of old and new" results for inputs of different shapes.
  • Debug printing: if you want to print values on every run, use jax.debug.print. It's compiled into the program and outputs on every execution.
counter = 0

@jax.jit
def double(x):
    global counter
    counter += 1          # side effect: runs only during tracing
    return x * 2

for i in range(3):
    double(jnp.float32(i))
print("After 3 calls, counter =", counter, "(incremented only during the first trace)")
double(jnp.ones(3))       # new shape → retrace
print("After a call with a different shape, counter =", counter)
assert counter == 2

# Reading a global variable: the value is frozen at trace time
scale = 2.0
@jax.jit
def scaled(x):
    return x * scale

print("With scale=2:", scaled(1.0))
scale = 10.0
print("After changing to scale=10, scalar input (cache hit):", scaled(1.0))
print("After changing to scale=10, new-shape input (recompiled):", scaled(jnp.ones(2)))
assert float(scaled(1.0)) == 2.0 and float(scaled(jnp.ones(2))[0]) == 10.0   # same function, old and new values side by side

# The right way to print for debugging
@jax.jit
def show(x):
    print("Python print: appears only during tracing, x =", x)
    jax.debug.print("jax.debug.print: appears on every run, x = {}", x)
    return x + 1

show(1.0)
_ = show(2.0)
After 3 calls, counter = 1 (incremented only during the first trace)
After a call with a different shape, counter = 2
With scale=2: 2.0
After changing to scale=10, scalar input (cache hit): 2.0
After changing to scale=10, new-shape input (recompiled): [10. 10.]
Python print: appears only during tracing, x = JitTracer(~float32[])
jax.debug.print: appears on every run, x = 1.0
jax.debug.print: appears on every run, x = 2.0

Conclusion: if you want a function's behavior to depend on some value, pass that value in as an argument (marked as a static argument if needed) instead of relying on a global variable. That's exactly where "explicit state passing" starts.


6.2 The challenge of state in functional code

JAX's jit and grad work by symbolically tracing pure functions. In standard Python, though, we're used to maintaining state through "in-place modification": append to a list, change a dict key, change an instance attribute. For example:

# Standard Python: modifying a dict in place
my_data = {'values': [10, 20]}

def update_data(data_dict, new_value):
    data_dict['values'].append(new_value)   # modify the list in place
    data_dict['last_added'] = new_value     # add a key in place
    # no return needed: the caller's object has already been changed

update_data(my_data, 30)
print(my_data)   # {'values': [10, 20, 30], 'last_added': 30}
{'values': [10, 20, 30], 'last_added': 30}

Under JAX transformations, this style causes three kinds of problems:

  1. It breaks jit: tracing captures the external state at that moment, and the compiled version may keep reusing that old value, so results come out wrong or not as expected.
  2. It interferes with grad: in-place modification blurs the data flow. You may get an error outright, or you may silently get mathematically wrong gradients, which sets a trap for training.
  3. It gets in the way of vmap / pmap: the mapped function is traced only once, so a modification to external mutable state inside it also happens only once, and what gets written is a tracer, not "one modification per example". Any independent per-example state has to be passed in as an argument with a batch dimension and passed back out as a return value.

Below is the course's example of "using JAX arrays but modifying an object's attribute in place". Without jit everything works; with jit, in JAX 0.12 it always raises a TypeError. The reason isn't the "attribute modification" itself: StateHolder is a plain Python object that is neither an array nor a registered PyTree, so jit doesn't know how to turn it into a traced value.

⚠️ Note: this code's behavior under jit isn't ambiguous, and it doesn't work in some versions and fail in others: it raises TypeError: Error interpreting argument ... as an abstract array every time. The case that really "goes wrong silently" is when you register the class as a PyTree (see the second half of this section).

⚠️ Note: the problem under vmap / pmap is not "multiple parallel instances reading and writing the same mutable state, causing conflicts and race conditions". vmap traces only once: the side effect of modifying an external list in the function body runs once for 5 examples, and what it gets is a BatchTracer (verified with an assert in the second half of this section). The real problem is that the side effect doesn't happen per example at all, and it may leak a tracer out of the function.

# ❌ Wrong: jit a function that modifies an object's attribute in place
class StateHolder:
    def __init__(self, value):
        self.value = jnp.array(value)

def update_state_impurely(state_obj, increment):
    # Impure: modifies the object's attribute in place
    state_obj.value = state_obj.value + increment
    return state_obj.value

# Without JIT: runs as ordinary Python and looks fine
my_state = StateHolder(10.0)
print("Non-JIT call 1:", update_state_impurely(my_state, 5.0))   # 15.0
print("Non-JIT call 2:", update_state_impurely(my_state, 3.0))   # 18.0

# With JIT: always raises TypeError
my_state_jit = StateHolder(10.0)
jitted_update = jax.jit(update_state_impurely)
stateholder_error = None
try:
    print("JIT call 1:", jitted_update(my_state_jit, 5.0), my_state_jit.value)
except Exception as e:
    stateholder_error = e
    print(f"Actual result → {type(e).__name__}: {str(e)[:200]}")

assert isinstance(stateholder_error, TypeError), "StateHolder should reliably raise TypeError under jit"
Non-JIT call 1: 15.0
Non-JIT call 2: 18.0
Actual result → TypeError: Error interpreting argument to <function update_state_impurely at 0x119ee1640> as an abstract array. The problematic value is of type <class '__main__.StateHolder'> and was passed to the function at p
# ✅ Fix: explicit state passing. The state is a plain array, the function returns the new state, and no object is modified
def update_state_pure(value, increment):
    return value + increment

jitted_pure = jax.jit(update_state_pure)
v = jnp.array(10.0)
v = jitted_pure(v, 5.0); print("JIT call 1:", v)
v = jitted_pure(v, 3.0); print("JIT call 2:", v)
assert float(v) == 18.0
JIT call 1: 15.0
JIT call 2: 18.0

Why it works: jit only understands arrays and PyTrees. Turning the state into an array (or a PyTree) and passing it out through the return value solves both the "can't be traced" problem and the "side effect" problem at once.

A sneakier kind of "silent failure": if you register the class as a PyTree (see 6.4 for how), jit runs fine, but what it gets inside jit is a copy rebuilt from the leaves. Changing an attribute on the copy has no effect on the caller's object, so there's no error, but the result is wrong: in the example below, the outside s.value stays at 10 the whole time, and the second return value is 13 instead of 18.

Also, JAX arrays themselves are immutable: x[0] = 1 raises an error right away; use the functional x = x.at[0].set(1) instead. It returns a new array (inside jit, XLA can usually optimize it into an in-place update).

The end of the cell below also shows how side effects actually behave under vmap (this goes with the note about "race conditions" above).

# Demo: once registered as a PyTree, in-place modification "fails silently"
@jax.tree_util.register_pytree_node_class
class StateHolderPytree:
    def __init__(self, value):
        self.value = value
    def tree_flatten(self):
        return (self.value,), None          # (children, auxiliary data)
    @classmethod
    def tree_unflatten(cls, aux, children):
        return cls(*children)

s = StateHolderPytree(jnp.array(10.0))
f = jax.jit(update_state_impurely)
r1 = f(s, 5.0); print("Return value:", r1, "  outside s.value:", s.value)
r2 = f(s, 3.0); print("Return value:", r2, "  outside s.value:", s.value, " ← not accumulated! A silent error")
assert float(s.value) == 10.0 and float(r2) == 13.0     # the outside object was never modified

# JAX arrays are immutable
x = jnp.zeros(3)
try:
    x[0] = 1.0
except TypeError as e:
    print("TypeError:", str(e)[:110], "...")
print("Functional update x.at[0].set(1.0) →", x.at[0].set(1.0), "; the original array is still", x)

# Side effects under vmap: run only once during tracing and get a BatchTracer standing for the whole batch; there are no "multiple instances racing"
side_effect_log = []
def append_and_double(v):
    side_effect_log.append(type(v).__name__)   # side effect: modifies an external list
    return v * 2
jax.vmap(append_and_double)(jnp.arange(5.0))
print("After vmap processed 5 examples, the external list was appended to", len(side_effect_log), "time(s):", side_effect_log)
assert side_effect_log == ["BatchTracer"]
Return value: 15.0   outside s.value: 10.0
Return value: 13.0   outside s.value: 10.0  ← not accumulated! A silent error
TypeError: JAX arrays are immutable and do not support in-place item assignment. Instead of x[idx] = y, use x = x.at[idx] ...
Functional update x.at[0].set(1.0) → [1. 0. 0.] ; the original array is still [0. 0. 0.]
After vmap processed 5 examples, the external list was appended to 1 time(s): ['BatchTracer']

Takeaway for this section: parameter updates, momentum, RNN hidden states and the like in training all need a different pattern: a function that updates state takes the current state as input and returns the new state as output, leaving the original state unchanged.


6.3 Pattern: explicit state passing

JAX's main way of handling state is explicit state passing, in three steps:

  1. Take the current state as an input argument;
  2. Compute with the state and the other inputs;
  3. Return the new, updated state (usually together with other results).

The state is passed around as ordinary data, and the original object is never modified. Model parameters, optimizer momentum and RNN hidden states can all be handled this way.

                ┌─────────────────────┐
 current state ─▶│                     │──▶ new state ──┐
                │   pure function f   │                │ (the caller keeps it and passes it in next time)
 other inputs ──▶│                     │──▶ other results
                └─────────────────────┘                │
                     ▲                                 │
                     └─────────────────────────────────┘

As a formula: $(s_{t+1},\ y_t) = f(s_t,\ x_t)$.

First, compare with the imperative style:

# Imperative: the state hides inside an object
class Counter:
    def __init__(self):
        self.count = 0
    def increment(self):
        self.count += 1
        return self.count

c = Counter()
print(c.increment())   # 1
print(c.increment())   # 2
print("Final count:", c.count)   # 2
1
2
Final count: 2
# Functional: the state is passed in and returned explicitly
def init_counter_state():
    """Initialize the state."""
    return 0

def increment(current_state):
    """Take the current state and return the new state."""
    print(f"Running increment function with state: {current_state}")  # for demonstration
    new_state = current_state + 1
    return new_state

state = init_counter_state()
print("Initial state:", state)
state = increment(state)
print("State after first increment:", state)
state = increment(state)
print("State after second increment:", state)
Initial state: 0
Running increment function with state: 0
State after first increment: 1
Running increment function with state: 1
State after second increment: 2

Key points:

  • The state is passed to increment explicitly as an argument;
  • increment returns a new value instead of modifying anything;
  • The caller is responsible for keeping the returned new state and passing it back in on the next call;
  • increment itself is a pure function (apart from the print used for demonstration).

Because it's pure, it can be compiled with jit directly:

jitted_increment = jax.jit(increment)

state = init_counter_state()
# The first call triggers tracing and compilation, which runs the Python code (including the print)
state = jitted_increment(state)
print("State after JIT call 1:", state)
# Later calls use the compiled version, so the print inside no longer runs
state = jitted_increment(state)
print("State after JIT call 2:", state)
state = jitted_increment(state)
print("State after JIT call 3:", state)
Running increment function with state: JitTracer(~int32[])
State after JIT call 1: 1
State after JIT call 2: 2
State after JIT call 3: 3

The first time jit is called with state of a given type and shape, it traces the function and compiles it with XLA; later calls with compatible shapes/types use the cache directly. The flow of state from input to output is itself part of the traced logic, so the compiled update stays correct. The same pattern works for grad too.

In practice, state is often a nested structure, such as a dict of parameters or optimizer statistics (means, variances). That's exactly the problem PyTrees solve in the next section.


6.4 Using PyTrees for structured state

Neural network parameters usually consist of weight matrices and bias vectors for many layers, organized by layer; optimizer state may also keep momentum or an adaptive learning rate for every parameter. Passing dozens of arrays in and out by hand, one by one, is tedious and error-prone.

A PyTree isn't a type you can import from JAX. It's "a tree structure made of nested Python containers":

  • Internal nodes: containers, most commonly list, tuple and dict (plus None, NamedTuple and others, see the Extra below);
  • Leaves: usually JAX arrays, or other non-container objects (Python scalars and so on).

For example, the parameters of a two-layer linear model:

params (dict)
├── layer1 (dict)
│   ├── weights : Array(3, 2)   ← leaf
│   └── bias    : Array(2,)     ← leaf
└── layer2 (dict)
    ├── weights : Array(2, 1)   ← leaf
    └── bias    : Array(1,)     ← leaf
params = {
    'layer1': {'weights': jnp.ones((3, 2)), 'bias': jnp.zeros((2,))},
    'layer2': {'weights': jnp.ones((2, 1)), 'bias': jnp.zeros((1,))},
}
print(jax.tree.map(lambda x: x.shape, params))
{'layer1': {'bias': (2,), 'weights': (3, 2)}, 'layer2': {'bias': (1,), 'weights': (2, 1)}}

JAX transformations support PyTrees out of the box. When jax.jit, jax.grad, jax.vmap or jax.pmap take or return a PyTree, they walk the tree structure automatically and apply their logic to the leaves, while keeping the container structure unchanged:

  • the gradient returned by jax.grad has the same PyTree structure as params;
  • jax.vmap handles the batch dimension on each leaf of the input PyTree;
  • jit handles the container structure automatically when compiling, so you don't have to flatten, update and rebuild the dict by hand.

To update parameters, use jax.tree.map to apply a function leaf by leaf across one or more PyTrees with the same structure; it returns a new PyTree with the same structure:

⚠️ Note: older code commonly uses jax.tree_util.tree_map. It still works in 0.12 and gives exactly the same result, but the officially recommended form is jax.tree.map, and that's what we use from here on. The full old-vs-new comparison is in the migration table later in this section.

def update_params(params, grads, learning_rate):
    """Apply one SGD update to every leaf of the PyTree."""
    def sgd_update(param, grad):
        return param - learning_rate * grad
    return jax.tree.map(sgd_update, params, grads)

# Placeholder gradients (same structure as params)
grads = {
    'layer1': {'weights': jnp.full((3, 2), 0.1), 'bias': jnp.full((2,), 0.01)},
    'layer2': {'weights': jnp.full((2, 1), 0.2), 'bias': jnp.full((1,), 0.02)},
}
learning_rate = 0.01

updated_params = update_params(params, grads, learning_rate)
print("layer1 weights:\n", updated_params['layer1']['weights'])
print("layer2 bias:", updated_params['layer2']['bias'])

# jit update_params directly; structure and values match the uncompiled version
jit_update_params = jax.jit(update_params)
updated_jit = jit_update_params(params, grads, learning_rate)
same = jax.tree.all(jax.tree.map(jnp.allclose, updated_params, updated_jit))
print("jit result matches the non-jit result:", bool(same))
assert same
layer1 weights:
 [[0.999 0.999]
 [0.999 0.999]
 [0.999 0.999]]
layer2 bias: [-0.0002]
jit result matches the non-jit result: True

Common functions in the jax.tree module (jax.tree_util has functions with the same names, and they're equivalent):

  • jax.tree.map(f, tree, *rest): apply a function leaf by leaf;
  • jax.tree.leaves(tree): pull out all the leaves into a flat list;
  • jax.tree.unflatten(treedef, leaves): rebuild a tree from a structure definition and leaves.

You usually only use these directly when your own function needs to work with the leaves explicitly (like update_params above); the rest of the time, JAX transformations quietly take care of the structure.

Summary: organize complex state with standard dicts, lists and tuples, and JAX handles the structure automatically inside transformations. That lets you write concise functional code while getting compilation, automatic differentiation and vectorization (vectorization, not quantization, which is something else).

🔍 Extra: PyTree API migration table (old → new, as tested on 0.12)

⚪ Optional: look here when migrating old code or checking the old and new forms of a tree API.

JAX now recommends the jax.tree module. jax.tree_util.* is still there and fully usable (some features, like registering custom nodes and keystr, exist only in tree_util). What was actually removed are the old aliases on the top-level jax namespace (jax.tree_map and friends): they were deprecated first, and by 0.12 they're gone, so calling them raises AttributeError. Section 6.7 runs into old code that uses jax.tree_map.

Old styleStatus in 0.12New style (recommended)
jax.tree_map(f, t, *rest)❌ Removedjax.tree.map
jax.tree_leaves(t)❌ Removedjax.tree.leaves
jax.tree_structure(t)❌ Removedjax.tree.structure
jax.tree_flatten(t) / jax.tree_unflatten(d, l)❌ Removedjax.tree.flatten / jax.tree.unflatten
jax.tree_transpose(o, i, t)❌ Removedjax.tree.transpose
jax.tree_reduce(f, t)❌ Removedjax.tree.reduce
jax.tree_util.tree_multimap(f, t, *rest)❌ Removed (earlier)jax.tree.map with several trees
jax.tree_util.tree_map✅ Availablejax.tree.map
jax.tree_util.tree_leaves / tree_structure✅ Availablejax.tree.leaves / jax.tree.structure
jax.tree_util.tree_flatten / tree_unflatten✅ Availablejax.tree.flatten / jax.tree.unflatten
jax.tree_util.tree_all / tree_reduce✅ Availablejax.tree.all / jax.tree.reduce
jax.tree_util.tree_transpose✅ Availablejax.tree.transpose
jax.tree_util.tree_map_with_path / tree_leaves_with_path✅ Availablejax.tree.map_with_path / jax.tree.leaves_with_path
jax.tree_util.tree_broadcast✅ Availablejax.tree.broadcast

The cell below first verifies the "status" column of this table with assert, then demonstrates the new APIs one by one.

# Self-verifying the migration table: none of the top-level old aliases exist, and both the tree_util and jax.tree versions do
removed_top_level = ["tree_map", "tree_leaves", "tree_structure", "tree_flatten",
                     "tree_unflatten", "tree_transpose", "tree_reduce"]
for name in removed_top_level:
    assert not hasattr(jax, name), f"jax.{name} should have been removed"
assert not hasattr(jax.tree_util, "tree_multimap")

still_in_tree_util = ["tree_map", "tree_leaves", "tree_structure", "tree_flatten", "tree_unflatten",
                      "tree_all", "tree_reduce", "tree_transpose", "tree_map_with_path",
                      "tree_leaves_with_path", "tree_broadcast"]
for name in still_in_tree_util:
    assert hasattr(jax.tree_util, name), f"jax.tree_util.{name} should still be available"

new_api = ["map", "leaves", "structure", "flatten", "unflatten", "all", "reduce",
           "transpose", "map_with_path", "leaves_with_path", "broadcast"]
for name in new_api:
    assert hasattr(jax.tree, name), f"jax.tree.{name} should exist"
print("Migration table verified; the jax.tree module provides:", [n for n in dir(jax.tree) if not n.startswith("_")])
Migration table verified; the jax.tree module provides: ['all', 'broadcast', 'flatten', 'flatten_with_path', 'leaves', 'leaves_with_path', 'map', 'map_with_path', 'reduce', 'reduce_associative', 'static', 'structure', 'transpose', 'unflatten']
# ❌ Old style (outdated): the top-level alias jax.tree_map was removed in 0.12
try:
    jax.tree_map(lambda x: x * 2, {'a': 1.0})
except AttributeError as e:
    print("Actual result → AttributeError:", e)
Actual result → AttributeError: module 'jax' has no attribute 'tree_map'
# ✅ Recommended: jax.tree.*, one function at a time
import operator
tree = {'b': jnp.arange(3.), 'a': (1.0, None, [2.0, 3.0])}

# (1) map: one tree, or several trees (several trees must have the same structure)
print("map, one tree:", jax.tree.map(lambda x: x * 2, {'w': 1.0, 'b': (2.0, 3.0)}))
summed = jax.tree.map(lambda a, b, c: a + b + c, {'w': 1.0, 'b': 2.0}, {'w': 10.0, 'b': 20.0}, {'w': 100.0, 'b': 200.0})
print("map, three trees:", summed)
assert summed == {'w': 111.0, 'b': 222.0}
try:
    jax.tree.map(lambda a, b: a + b, {'w': 1.0}, {'w': 1.0, 'b': 2.0})
except ValueError as e:
    print("Mismatched structures → ValueError:", str(e).splitlines()[0])

# (2) leaves: None is an "empty subtree", not a leaf; dicts are flattened in sorted key order ('a' before 'b')
lv = jax.tree.leaves(tree)
print("leaves:", lv)
assert len(lv) == 4 and lv[0] == 1.0

# (3) structure
print("structure:", jax.tree.structure(tree))

# (4) flatten / unflatten: a round trip
leaves, treedef = jax.tree.flatten(tree)
rebuilt = jax.tree.unflatten(treedef, [x * 10 for x in leaves])
print("unflatten:", rebuilt)
assert jax.tree.structure(rebuilt) == treedef

# (5) map_with_path: get the key path of each leaf, e.g. to apply weight decay to weights only
decayed = jax.tree.map_with_path(
    lambda path, x: x * 0.9 if 'weights' in jax.tree_util.keystr(path) else x, params)
print("map_with_path paths:", jax.tree.map_with_path(lambda p, x: jax.tree_util.keystr(p), params))
assert jnp.allclose(decayed['layer1']['weights'], 0.9) and jnp.allclose(decayed['layer1']['bias'], 0.0)

# (6) reduce: reduce all leaves to a single value, e.g. count parameters or compute the global gradient norm
n_params = jax.tree.reduce(operator.add, jax.tree.map(lambda x: x.size, params))
global_norm = jnp.sqrt(jax.tree.reduce(operator.add, jax.tree.map(lambda g: jnp.sum(g ** 2), grads)))
print("reduce, parameter count:", n_params, " global gradient norm:", global_norm)
assert n_params == 11

# (7) all: true only if every leaf is true
print("all:", jax.tree.all({'x': True, 'y': [True, True]}), jax.tree.all({'x': True, 'y': [True, False]}))
assert jax.tree.all({'x': True, 'y': [True, True]}) and not jax.tree.all({'x': True, 'y': [True, False]})

# (8) transpose: "list of dicts" ↔ "dict of lists", handy for regrouping per-step metrics by name
records = [{'loss': 1.0, 'acc': 0.5}, {'loss': 0.8, 'acc': 0.6}, {'loss': 0.5, 'acc': 0.7}]
outer = jax.tree.structure([0] * len(records))
inner = jax.tree.structure({'loss': 0, 'acc': 0})
by_key = jax.tree.transpose(outer, inner, records)
print("transpose:", by_key)
assert by_key == {'acc': [0.5, 0.6, 0.7], 'loss': [1.0, 0.8, 0.5]}
map, one tree: {'b': (4.0, 6.0), 'w': 2.0}
map, three trees: {'b': 222.0, 'w': 111.0}
Mismatched structures → ValueError: pytree structure error: different numbers of pytree children at key path
leaves: [1.0, 2.0, 3.0, Array([0., 1., 2.], dtype=float32)]
structure: PyTreeDef({'a': (*, None, [*, *]), 'b': *})
unflatten: {'a': (10.0, None, [20.0, 30.0]), 'b': Array([ 0., 10., 20.], dtype=float32)}
map_with_path paths: {'layer1': {'bias': "['layer1']['bias']", 'weights': "['layer1']['weights']"}, 'layer2': {'bias': "['layer2']['bias']", 'weights': "['layer2']['weights']"}}
reduce, parameter count: 11  global gradient norm: 0.37496668
all: True False
transpose: {'acc': [0.5, 0.6, 0.7], 'loss': [1.0, 0.8, 0.5]}

🔍 Extra: custom state containers: NamedTuple, register_dataclass, register_pytree_node_class

⚪ Optional: read this if you want to use a container with named fields (a TrainState and so on) instead of a dict.

Dicts are convenient to write, but a mistyped key only shows up at run time. In practice, "containers with named fields" are more common for representing state:

  1. typing.NamedTuple: a PyTree out of the box (JAX recognizes it automatically). It's immutable, and ._replace() gives you a modified copy. Section 6.6 uses it.
  2. jax.tree_util.register_dataclass: registers a @dataclass as a PyTree. You can specify data_fields / meta_fields explicitly, or just use it as a decorator and let JAX infer from the fields. A field with field(metadata=dict(static=True)) becomes static metadata: it isn't traced, and a change in its value triggers recompilation. It's a good idea to pair it with frozen=True, which rules out in-place modification at the syntax level.
  3. jax.tree_util.register_pytree_node_class: the most flexible; you write tree_flatten / tree_unflatten yourself. Good for classes with an unusual structure.
# 1) NamedTuple
class TrainState(NamedTuple):
    step: jax.Array
    params: dict

ts = TrainState(step=jnp.array(0), params={'w': jnp.ones(2)})
ts2 = ts._replace(step=ts.step + 1)        # creates a new object; the original is unchanged
print("NamedTuple structure:", jax.tree.structure(ts))
print("ts.step =", ts.step, " ts2.step =", ts2.step)

# 2) register_dataclass: specify the data fields and metadata fields explicitly
@partial(jax.tree_util.register_dataclass, data_fields=['w', 'b'], meta_fields=['name'])
@dataclass(frozen=True)
class Linear:
    w: jax.Array
    b: jax.Array
    name: str          # static metadata, not a leaf

lin = Linear(jnp.ones(2), jnp.zeros(2), 'fc1')
print("dataclass leaves:", jax.tree.leaves(lin))
print("dataclass structure:", jax.tree.structure(lin))

# You can also use it directly as a decorator and let the field metadata decide what's static
@jax.tree_util.register_dataclass
@dataclass(frozen=True)
class AdamConfig:
    lr: jax.Array
    b1: float = field(default=0.9, metadata=dict(static=True))

print("Inferred:", jax.tree.structure(AdamConfig(jnp.array(1e-3))))

# frozen=True: an attempt to modify in place errors immediately
try:
    lin.w = jnp.zeros(2)
except Exception as e:
    print(f"{type(e).__name__}: {e}")

# All of these containers pass straight through jit / grad
@jax.jit
def forward(layer, x):
    return x @ layer.w + layer.b.sum()

print("jit forward:", forward(lin, jnp.array([1.0, 2.0])))
print("Gradient with respect to the dataclass:", jax.grad(lambda l: forward(l, jnp.array([1.0, 2.0])))(lin))
NamedTuple structure: PyTreeDef(CustomNode(namedtuple[TrainState], [*, {'w': *}]))
ts.step = 0  ts2.step = 1
dataclass leaves: [Array([1., 1.], dtype=float32), Array([0., 0.], dtype=float32)]
dataclass structure: PyTreeDef(CustomNode(Linear[('fc1',)], [*, *]))
Inferred: PyTreeDef(CustomNode(AdamConfig[(0.9,)], [*]))
FrozenInstanceError: cannot assign to field 'w'
jit forward: 3.0
Gradient with respect to the dataclass: Linear(w=Array([1., 2.], dtype=float32), b=Array([1., 1.], dtype=float32), name='fc1')

6.5 Example: a stateful counter

First, recall the standard Python counter class. It relies on mutable state (a side effect) and isn't compatible with JAX: if you apply jit to increment directly, JAX can't trace the in-place change to self.count.

class Counter:
    def __init__(self):
        self.count = 0
    def increment(self):
        self.count += 1     # in-place modification: a side effect
        return self.count

counter_obj = Counter()
print(counter_obj.increment(), counter_obj.increment())
1 2

A functional implementation: the state is just an ordinary value. The update function takes the old state and returns the new state. The output of one call is the input to the next, and that's how the state "flows" through a chain of pure function calls.

initial_state = 0

def update_counter(current_state):
    """Increment the input state by 1."""
    print("Tracing update_counter...")   # shows up only during tracing (for demonstration)
    return current_state + 1

state1 = update_counter(initial_state)
state2 = update_counter(state1)
state3 = update_counter(state2)
print("Final state:", state3)
Tracing update_counter...
Tracing update_counter...
Tracing update_counter...
Final state: 3

Compatible with JIT: after jax.jit(update_counter), calling it three times in a row prints "Tracing update_counter..." only once; the later calls use the cached compiled version. This shows that jit handles state managed in a functional way correctly. It only traces again when the input's abstract signature (shape, dtype, weak-type flag) changes.

⚠️ Note: you'll sometimes hear that "when the input type changes, say from a Python int to a JAX tracer, you may get multiple traces". That's not accurate: a tracer is jit's internal traced value, not a type the caller passes in. When you start calling from the Python int 0, what comes back is a weakly typed int32 array whose signature is the same as a Python int's, so it traces only once (verified with assert below). The case that really causes an extra trace is a weak type turning into a strong type; see Exercise 2 in 6.8.

counter_traces = []
def update_counter_counted(current_state):
    counter_traces.append(1)          # runs only during tracing; used to count traces
    return update_counter(current_state)

jitted_update_counter = jax.jit(update_counter_counted)

s = initial_state
for i in range(3):
    s = jitted_update_counter(s)
    print(f"After call {i + 1}: {s}")
assert len(counter_traces) == 1 and int(s) == 3
Tracing update_counter...
After call 1: 1
After call 2: 2
After call 3: 3

Iterating with lax.scan

Calling a jitted function over and over in a Python loop costs Python dispatch overhead every time. jax.lax.scan hands the whole job of "repeatedly applying a body function to an accumulated state" to the compiler:

  • Body function signature: body(carry, x) -> (new_carry, y)
    • carry: the state passed from step to step;
    • x: this step's input slice (from xs; can be None);
    • y: this step's output. The y values from all steps are stacked along a new axis 0 and returned.
  • Call: final_carry, ys = lax.scan(body, init_carry, xs, length=...)

Its semantics are equivalent to this Python code:

def scan(f, init, xs, length=None):
    carry, ys = init, []
    for x in xs:                 # if xs is None, loop length times with x = None
        carry, y = f(carry, x)
        ys.append(y)
    return carry, stack(ys)
def scan_body(carry_state, _):
    next_state = update_counter(carry_state)
    return next_state, None          # no extra output per step

initial_scan_state = 0
num_steps = 10
final_state, accumulated_outputs = jax.lax.scan(
    scan_body, initial_scan_state, xs=None, length=num_steps)
print("Final state after scan:", final_state)
print("Per-step outputs:", accumulated_outputs)   # each step outputs None, so the whole thing is None too
Tracing update_counter...
Final state after scan: 10
Per-step outputs: None

Put lax.scan inside jax.jit and the whole loop compiles into one optimized program, which is much faster than calling a jitted function over and over in a Python loop. One thing to watch: scan's length must be a Python integer known at compile time, because it determines the shape of the output ys. So the number of iterations must either be passed to jit as a static argument, or you switch to lax.fori_loop, which allows a dynamic count.

⚠️ Note: if, as in run_scan_jitted(init_state, steps), you pass steps as an ordinary jit argument straight into length, you get ConcretizationTypeError: The length argument to scan expects a concrete int value. Below we first run that version, then show two fixes.

# ❌ Wrong: steps is passed into jit as an ordinary argument and then into scan's length
@jax.jit
def run_scan_jitted(init_state, steps):
    final, _ = jax.lax.scan(scan_body, init_state, xs=None, length=steps)
    return final

scan_error = None
try:
    print(run_scan_jitted(initial_scan_state, num_steps))
except Exception as e:
    scan_error = e
    print(f"Actual result → {type(e).__name__}:\n{str(e)[:250]}")

assert isinstance(scan_error, jax.errors.ConcretizationTypeError)
Actual result → ConcretizationTypeError:
Abstract tracer value encountered where concrete value is expected: traced array with shape int32[]
The `length` argument to `scan` expects a concrete `int` value. For scan-like iteration with a dynamic length, use `while_loop` or `fori_loop`.
The er
# ✅ Fix 1: mark steps as static with static_argnames (each distinct value of steps compiles once)
@partial(jax.jit, static_argnames="steps")
def run_scan_jitted(init_state, steps):
    final, _ = jax.lax.scan(scan_body, init_state, xs=None, length=steps)
    return final

r_static = run_scan_jitted(initial_scan_state, steps=num_steps)
print("Fix 1 (static_argnames):", r_static)
assert int(r_static) == 10

# ✅ Fix 2: when the loop count needs to change at run time and you don't need per-step outputs, use fori_loop
@jax.jit
def run_fori(init_state, steps):
    return lax.fori_loop(0, steps, lambda i, c: update_counter(c), init_state)

r_fori_10, r_fori_25 = run_fori(initial_scan_state, 10), run_fori(initial_scan_state, 25)
print("Fix 2 (fori_loop):", r_fori_10, r_fori_25)
assert int(r_fori_10) == 10 and int(r_fori_25) == 25
Fix 1 (static_argnames): 10
Tracing update_counter...
Fix 2 (fori_loop): 10 25

Why: ordinary jit arguments are abstract tracers during tracing, with only a shape and a dtype and no concrete value; scan needs a concrete step count to determine the output shape. static_argnames lets steps take part in compilation as a Python integer (at the cost of recompiling for every new value); when fori_loop's upper bound is a dynamic value, it becomes a while_loop underneath, so the count can change at run time, but reverse-mode differentiation isn't supported.

Scaling up to complex state: the same pattern works for nested dicts or lists of JAX arrays (model parameters, optimizer statistics and so on). As long as the update function takes the whole PyTree and returns the whole updated PyTree, JAX transformations handle it correctly.

Summary: treat state as an immutable value and pass it into and out of pure functions explicitly. That keeps you compatible with transformations like jit, and lets you use efficient execution tools like lax.scan.

🔍 Extra: lax.scan's carry is the state; record a trajectory with per-step outputs

🟢 Must-read

The example above outputs None at every step, so it doesn't show off the most useful side of scan: the carry is the state, and y is "whatever you want to record at each step". Below, a PyTree state with two fields (running sum, step count) goes into the carry, and the mean at each step is collected as ys. A few rules:

  • The carry's PyTree structure, shapes and dtypes must stay the same at every step, or you get an error (a common cause: the initial value is int32, but the body function returns float32). There's one exception: if the initial value is weakly typed (a Python scalar, or jnp.array(0) without a dtype), scan first promotes it to the output's type and doesn't error;
  • xs can be a PyTree, and scan slices each leaf along axis 0;
  • scan itself can be transformed further by grad and vmap.
def running_mean_body(carry, x):
    new_carry = {'sum': carry['sum'] + x, 'n': carry['n'] + 1}
    y = new_carry['sum'] / new_carry['n']        # per-step output: the current mean
    return new_carry, y

xs = jnp.array([2.0, 4.0, 6.0, 8.0, 10.0])
init = {'sum': jnp.array(0.0), 'n': jnp.array(0)}
final, means = lax.scan(running_mean_body, init, xs)
print("Final state:", final)
print("Mean at each step:", means)
assert jnp.allclose(means, jnp.array([2.0, 3.0, 4.0, 5.0, 6.0]))

# A carry whose dtype changes raises an error
def add_body(carry, x):
    return carry + x, None          # int state + float input → float

try:
    lax.scan(add_body, jnp.array(0, dtype=jnp.int32), xs)    # explicit int32 initial state
except TypeError as e:
    print("dtype mismatch → TypeError:", next(l for l in str(e).splitlines() if "input carry" in l)[:150])

# Exception: weak types (Python scalars, values like jnp.array(0) with no dtype given) are allowed to be promoted
print("Weakly typed initial value jnp.array(0):", lax.scan(add_body, jnp.array(0), xs)[0], "(no error)")

# A shape mismatch raises an error too
try:
    lax.scan(lambda c, x: (jnp.stack([c, c]), None), jnp.array(0.0), xs)
except TypeError as e:
    print("shape mismatch → TypeError:", next(l for l in str(e).splitlines() if "input carry" in l)[:150])
Final state: {'n': Array(5, dtype=int32, weak_type=True), 'sum': Array(30., dtype=float32)}
Mean at each step: [2. 3. 4. 5. 6.]
dtype mismatch → TypeError: The input carry carry has type int32[] but the corresponding output carry component has type float32[], so the dtypes do not match.
Weakly typed initial value jnp.array(0): 30.0 (no error)
shape mismatch → TypeError: The input carry c has type float32[] but the corresponding output carry component has type float32[2], so the shapes do not match.

🔍 Extra: a random key is itself a piece of state that must be passed explicitly

🟢 Must-read

JAX's PRNG is stateless: a random function depends only on the key passed in, and the same key always gives the same result. So a key has to be passed in and out explicitly like a parameter, and each key is used only once: key, subkey = jax.random.split(key), sample with subkey, and keep passing key along; inside lax.scan, put the key in the carry. For the difference between jax.random.key and the old PRNGKey, see Chapter 1; for a full demonstration of management patterns such as split / fold_in / scan carry, plus common pitfalls, see Advanced 10, "Random numbers and sharding", §5–§6.


6.6 Example: simple optimizer state

Optimizers in machine learning (SGD, Adam and so on) update parameters step by step based on gradients. Many optimizers also keep internal state, such as a momentum vector or a separate adaptive learning rate for each parameter.

The most basic gradient descent:

$$ \theta_{\text{new}} = \theta_{\text{old}} - \eta\, \nabla f(\theta_{\text{old}}) $$

where $\eta$ is the learning rate. Take minimizing $f(x) = (x-3)^2$ as an example: the minimum is at $x=3$, and the gradient is $f'(x) = 2(x-3)$.

def objective_function(x):
    return (x - 3.0) ** 2

grad_fn = jax.grad(objective_function)

def sgd_update(params, gradients, learning_rate):
    """Pure function: return new parameters based on the gradients."""
    return params - learning_rate * gradients

current_params = jnp.array(0.0)
learning_rate = 0.1
num_steps = 20

print(f"Initial parameter: {current_params:.4f}")
for step in range(num_steps):
    gradients = grad_fn(current_params)
    next_params = sgd_update(current_params, gradients, learning_rate)
    current_params = next_params          # explicitly catch the new state
    if (step + 1) % 5 == 0:
        print(f"Step {step + 1:2d}: parameter = {current_params:.4f}, gradient = {gradients:.4f}")
print(f"Final optimized parameter: {current_params:.4f}")
sgd_final = float(current_params)       # keep it to compare with momentum later
Initial parameter: 0.0000
Step  5: parameter = 2.0170, gradient = -2.4576
Step 10: parameter = 2.6779, gradient = -0.8053
Step 15: parameter = 2.8944, gradient = -0.2639
Step 20: parameter = 2.9654, gradient = -0.0865
Final optimized parameter: 2.9654

sgd_update is a pure function: the "parameters" state is passed in explicitly and returned again.

Adding optimizer state: SGD with momentum

Momentum keeps a "velocity" $v$. It accumulates past gradients, which smooths the updates and speeds things up when the gradient keeps pointing the same way:

$$ v_{\text{new}} = \mu\, v_{\text{old}} + \eta\, \nabla f(\theta_{\text{old}}), \qquad \theta_{\text{new}} = \theta_{\text{old}} - v_{\text{new}} $$

where $\mu$ is the momentum coefficient. The state now has two parts (parameters and velocity), so we bundle them with typing.NamedTuple (a dict {'params': ..., 'velocity': ...} would work too). A NamedTuple is a valid PyTree on its own.

Momentum isn't faster on every problem. Its advantage is clearest on ill-conditioned problems (where the curvature differs a lot between directions). On a 1-D quadratic like $f(x)=(x-3)^2$ with curvature 2, with $\eta=0.1$ and momentum set to 0.9, the system is underdamped and oscillates back and forth around the optimum.

⚠️ Note: demonstrating momentum with $\eta=0.1,\ \mu=0.9$ actually does worse than plain SGD: after 20 steps the parameter sits at 4.0478, further from the optimum 3 than plain SGD's 2.9654. Setting $\mu$ to 0.5 converges to 2.9992 in 20 steps. Below we first run the $\mu=0.9$ version, then the tuned one.

# ❌ Poor hyperparameter: momentum=0.9 is underdamped on this problem and doesn't converge within 20 steps
class OptimizerState(NamedTuple):
    params: jax.Array
    velocity: jax.Array

def momentum_update(state, gradients, learning_rate, momentum):
    """Pure function: take the old optimizer state and return the new optimizer state."""
    new_velocity = momentum * state.velocity + learning_rate * gradients
    new_params = state.params - new_velocity
    return OptimizerState(params=new_params, velocity=new_velocity)

initial_params = jnp.array(0.0)
initial_velocity = jnp.array(0.0)
current_state = OptimizerState(params=initial_params, velocity=initial_velocity)

learning_rate = 0.1
momentum_coeff = 0.9
num_steps = 20

print(f"Initial state: parameter = {current_state.params:.4f}, velocity = {current_state.velocity:.4f}")
for step in range(num_steps):
    gradients = grad_fn(current_state.params)
    current_state = momentum_update(current_state, gradients, learning_rate, momentum_coeff)
    if (step + 1) % 5 == 0:
        print(f"Step {step + 1:2d}: parameter = {current_state.params:.4f}, "
              f"velocity = {current_state.velocity:.4f}, gradient = {gradients:.4f}")
print(f"Final optimized parameter: {current_state.params:.4f}")
momentum09_final = float(current_state.params)
print(f"Measured: |momentum μ=0.9 − 3| = {abs(momentum09_final - 3):.4f}  >  |plain SGD − 3| = {abs(sgd_final - 3):.4f}")
assert abs(momentum09_final - 3) > abs(sgd_final - 3)     # momentum with μ=0.9 actually does worse than plain SGD
Initial state: parameter = 0.0000, velocity = 0.0000
Step  5: parameter = 4.7413, velocity = -0.8155, gradient = 1.8516
Step 10: parameter = 2.9868, velocity = 0.8358, gradient = 1.6452
Step 15: parameter = 1.9853, velocity = -0.3751, gradient = -2.7795
Step 20: parameter = 4.0478, velocity = -0.1091, gradient = 1.8774
Final optimized parameter: 4.0478
Measured: |momentum μ=0.9 − 3| = 1.0478  >  |plain SGD − 3| = 0.0346
# ✅ Fix: the same momentum_update, with only the momentum coefficient changed to 0.5 (learning rate unchanged)
def run_momentum(mu, lr=0.1, steps=20):
    st = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
    for _ in range(steps):
        st = momentum_update(st, grad_fn(st.params), lr, mu)
    return float(st.params)

for mu_ in [0.0, 0.3, 0.5, 0.9]:
    print(f"momentum={mu_}: parameter after 20 steps = {run_momentum(mu_):.4f}")

momentum05_final = run_momentum(0.5)
assert abs(momentum05_final - 3) < 1e-2                    # converges after tuning
assert abs(momentum05_final - 3) < abs(sgd_final - 3)      # and beats plain SGD
momentum=0.0: parameter after 20 steps = 2.9654
momentum=0.3: parameter after 20 steps = 2.9997
momentum=0.5: parameter after 20 steps = 2.9992
momentum=0.9: parameter after 20 steps = 4.0478

Why: on a quadratic, momentum behaves like a second-order linear system. When $\mu$ is too large there isn't enough damping, so the parameter overshoots the optimum and swings back; a moderate $\mu$ converges fastest. In practice 0.9 is the usual default momentum, but it often needs a smaller learning rate to go with it.

Compatibility with JAX transformations: momentum_update is a pure function whose output depends only on its inputs, so jax.jit can trace and compile it without trouble. Numeric hyperparameters like the learning rate and momentum coefficient should be passed in as ordinary (dynamic) arguments: they're just scalars and don't change the program's structure, and as dynamic arguments every value shares the same compiled result. Save static arguments for things that really affect the program structure (shapes, loop counts, boolean switches, the choice of activation function, and so on). For the full table of what triggers recompilation, see Chapter 2, section 2.3; for how to deal with it, see Chapter 7, topic 1.

⚠️ Note: a common pattern is jax.jit(momentum_update, static_argnums=(2, 3)), making learning_rate and momentum static arguments, on the grounds that "marking them static avoids unnecessary recompilation". It's actually the opposite: the values of static arguments are part of the compilation cache key, so every new value triggers a recompile. Try it with a 5-value learning rate schedule: the static version traces 5 times, the dynamic version only once. With a constant learning rate both compile only once and the code itself runs fine; but as soon as this habit meets a learning rate schedule, it recompiles on every step.

# ❌ Wrong: learning rate and momentum as static arguments (recompiles every step with a learning rate schedule)
momentum_traces = []
def momentum_update_logged(state, gradients, learning_rate, momentum):
    momentum_traces.append(learning_rate)        # runs only during tracing; used to count traces
    return momentum_update(state, gradients, learning_rate, momentum)

jitted_momentum_update = jax.jit(momentum_update_logged, static_argnums=(2, 3))

schedule = [0.1, 0.09, 0.08, 0.07, 0.06]   # simulated learning rate decay
current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
print("--- Running the JIT-compiled update (static learning rate) ---")
for lr_t in schedule:
    current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), lr_t, 0.5)
print(f"parameter = {current_state.params:.4f}; traces = {len(momentum_traces)}")
assert len(momentum_traces) == 5
--- Running the JIT-compiled update (static learning rate) ---
parameter = 2.7258; traces = 5
# ✅ Fix: pass the learning rate and momentum as ordinary (dynamic) arguments
momentum_traces.clear()
jitted_momentum_update = jax.jit(momentum_update_logged)

current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
print("--- Running the JIT-compiled update (dynamic learning rate) ---")
for lr_t in schedule:
    current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), lr_t, 0.5)
print(f"parameter = {current_state.params:.4f}; traces = {len(momentum_traces)}")
assert len(momentum_traces) == 1

# Constant learning rate, full 20 steps: matches the uncompiled version
current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
for step in range(num_steps):
    current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), 0.1, 0.5)
print(f"Final optimized parameter (JIT-compiled, μ=0.5): {current_state.params:.4f}")
assert jnp.allclose(current_state.params, momentum05_final)
--- Running the JIT-compiled update (dynamic learning rate) ---
parameter = 2.7258; traces = 1
Final optimized parameter (JIT-compiled, μ=0.5): 2.9992

Summary: explicit state passing plus a PyTree structure (here a NamedTuple) gives you training loops that are clear, easy to manage, and compatible with both jit and grad. It's the foundation for building more complex models and training pipelines.

🔍 Extra: two ways to write the momentum formula

⚪ Optional: read this when comparing against the momentum formula in PyTorch / Optax or a paper.

The formulation of Sutskever et al. (the one used above) puts the learning rate inside the velocity: $v \leftarrow \mu v + \eta g,\ \theta \leftarrow \theta - v$. PyTorch and Optax's sgd(momentum=...) use $v \leftarrow \mu v + g,\ \theta \leftarrow \theta - \eta v$. With a constant learning rate the two are exactly equivalent ($v_{\text{Sutskever}} = \eta\, v_{\text{PyTorch}}$); when the learning rate changes over time, they behave differently. Keep this in mind when porting code or comparing with a paper.

# Check that the two momentum formulas are equivalent with a constant learning rate
g_fn = jax.grad(lambda x: (x - 3.0) ** 2)
eta, mu = 0.1, 0.9
x1, v1 = 0.0, 0.0      # Sutskever form
x2, v2 = 0.0, 0.0      # PyTorch / Optax form
for _ in range(20):
    g1 = g_fn(x1); v1 = mu * v1 + eta * g1; x1 = x1 - v1
    g2 = g_fn(x2); v2 = mu * v2 + g2;       x2 = x2 - eta * v2
print(f"Sutskever form: {float(x1):.6f}   PyTorch/Optax form: {float(x2):.6f}")
assert jnp.allclose(x1, x2, atol=1e-5)
Sutskever form: 4.047758   PyTorch/Optax form: 4.047758

🔍 Extra: generic PyTree optimizers (momentum SGD and Adam) + a full training loop

⚪ Optional: read this if you want to implement optimizers that work on any PyTree yourself, or to understand Optax's interface; Extra exercises 3 and 4 use the code here.

The optimizers so far handle a single scalar parameter. In real use, the parameters are a PyTree, and the optimizer state should be a PyTree whose structure matches it. Below, following Optax's interface, we write two optimizers that work on any PyTree:

  • init(params) -> opt_state: create the initial state from the parameter structure;
  • update(grads, opt_state, params) -> (updates, new_opt_state): return the parameter increments and the new state;
  • apply_updates(params, updates): add the increments to the parameters.

The state of momentum SGD (Optax/PyTorch form) is the velocity $v$ (called trace in Optax):

$$ v_t = \mu\, v_{t-1} + g_t, \qquad \Delta\theta_t = -\eta\, v_t, \qquad v_0 = 0 . $$

The state of Adam consists of the step count $t$, the first moment $m$ and the second moment $v$:

$$ \begin{aligned} m_t &= \beta_1 m_{t-1} + (1-\beta_1)\, g_t, & v_t &= \beta_2 v_{t-1} + (1-\beta_2)\, g_t^2, & m_0 = v_0 &= 0,\\ \hat m_t &= \frac{m_t}{1-\beta_1^{\,t}}, & \hat v_t &= \frac{v_t}{1-\beta_2^{\,t}}, & t &= 1, 2, \dots\\ \Delta\theta_t &= -\eta\,\frac{\hat m_t}{\sqrt{\hat v_t + \epsilon_{\text{root}}}+\epsilon}. & & & & \end{aligned} $$

What bias correction is for: $m_0=v_0=0$ biases the early moment estimates toward 0 (at step 1, $m_1=(1-\beta_1)g_1$, only 10% of the true gradient). Dividing by $1-\beta^t$ cancels exactly this bias: at step 1, $\hat m_1 = g_1$ and $\hat v_1 = g_1^2$, so the first update has a size of about $\eta$, regardless of the gradient's magnitude.

These two optimizers correspond to Optax's trace + scale_by_learning_rate and scale_by_adam + scale_by_learning_rate. For a term-by-term comparison with Optax, the composable chain style, and an actual cross-check against optax (optax 0.2.9 is installed in this environment), see Chapter 7, topic 5, "Hand-written optimizers". Below, we first verify that the implementation here is correct against a reference computed by hand with NumPy.

Finally, we fit $y=\sin(x)$ with a small MLP, using "parameters + optimizer state" as the carry of lax.scan so the whole training run compiles into a single program.

# ---------- Generic optimizers: interface modeled on Optax ----------
class Optimizer(NamedTuple):
    init: callable
    update: callable

def apply_updates(params, updates):
    return jax.tree.map(lambda p, u: p + u, params, updates)

class MomentumState(NamedTuple):
    velocity: dict

def sgd_momentum(lr, momentum=0.9):
    def init(params):
        return MomentumState(velocity=jax.tree.map(jnp.zeros_like, params))
    def update(grads, state, params=None):
        v = jax.tree.map(lambda v, g: momentum * v + g, state.velocity, grads)
        updates = jax.tree.map(lambda v: -lr * v, v)
        return updates, MomentumState(velocity=v)
    return Optimizer(init, update)

class AdamState(NamedTuple):
    count: jax.Array
    mu: dict
    nu: dict

def adam(lr, b1=0.9, b2=0.999, eps=1e-8):
    def init(params):
        # Note: mu and nu must be two independent sets of arrays; they can't share the same zeros object (see the donate_argnums Extra)
        return AdamState(count=jnp.zeros([], jnp.int32),
                         mu=jax.tree.map(jnp.zeros_like, params),
                         nu=jax.tree.map(jnp.zeros_like, params))
    def update(grads, state, params=None):
        t = state.count + 1
        mu = jax.tree.map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, grads)
        nu = jax.tree.map(lambda v, g: b2 * v + (1 - b2) * g ** 2, state.nu, grads)
        bc1 = 1 - b1 ** t                       # bias correction
        bc2 = 1 - b2 ** t
        updates = jax.tree.map(lambda m, v: -lr * (m / bc1) / (jnp.sqrt(v / bc2) + eps), mu, nu)
        return updates, AdamState(count=t, mu=mu, nu=nu)
    return Optimizer(init, update)

# ---------- Model and data ----------
def init_mlp(key, sizes=(1, 32, 32, 1)):
    params = []
    keys = jax.random.split(key, len(sizes) - 1)
    for k, (n_in, n_out) in zip(keys, zip(sizes[:-1], sizes[1:])):
        params.append({'w': jax.random.normal(k, (n_in, n_out)) * jnp.sqrt(2.0 / n_in),
                       'b': jnp.zeros(n_out)})
    return params

def mlp(params, x):
    for layer in params[:-1]:
        x = jnp.tanh(x @ layer['w'] + layer['b'])
    return x @ params[-1]['w'] + params[-1]['b']

def loss_fn(params, x, y):
    return jnp.mean((mlp(params, x) - y) ** 2)

key = jax.random.key(0)
key, k_data, k_init = jax.random.split(key, 3)
X = jax.random.uniform(k_data, (256, 1), minval=-3.0, maxval=3.0)
Y = jnp.sin(X)
params0 = init_mlp(k_init)
print("Parameter structure:", jax.tree.map(jnp.shape, params0))
Parameter structure: [{'b': (32,), 'w': (1, 32)}, {'b': (32,), 'w': (32, 32)}, {'b': (1,), 'w': (32, 1)}]
import numpy as np

# Self-check on a quadratic: the hand-written Adam / momentum SGD match a NumPy reference computed by hand, step by step
A = np.array([1.0, 4.0, 0.25], dtype=np.float32)        # different curvature in each dimension
C = np.array([3.0, -1.0, 2.0], dtype=np.float32)        # the optimum

def quad_loss(p):                                        # f(x) = 1/2 Σ a_i (x_i - c_i)^2
    return 0.5 * jnp.sum(A * (p['x'] - C) ** 2)

def numpy_adam_reference(x, steps, lr=0.1, b1=0.9, b2=0.999, eps=1e-8):
    m = np.zeros_like(x); v = np.zeros_like(x); traj = []
    for t in range(1, steps + 1):
        g = A * (x - C)                                  # gradient computed by hand
        m = b1 * m + (1 - b1) * g
        v = b2 * v + (1 - b2) * g ** 2
        m_hat = m / (1 - b1 ** t); v_hat = v / (1 - b2 ** t)
        x = x - lr * m_hat / (np.sqrt(v_hat) + eps)
        traj.append(x.copy())
    return traj

def numpy_momentum_reference(x, steps, lr=0.1, mu=0.9):
    v = np.zeros_like(x); traj = []
    for _ in range(steps):
        g = A * (x - C)
        v = mu * v + g
        x = x - lr * v
        traj.append(x.copy())
    return traj

def run_jax_optimizer(opt, steps):
    p = {'x': jnp.zeros(3)}
    state = opt.init(p); traj = []
    for _ in range(steps):
        g = jax.grad(quad_loss)(p)
        updates, state = opt.update(g, state, p)
        p = apply_updates(p, updates)
        traj.append(np.asarray(p['x']))
    return traj, state

x0 = np.zeros(3, dtype=np.float32)
adam_traj, adam_state = run_jax_optimizer(adam(0.1), 3)
for t, (ours, ref) in enumerate(zip(adam_traj, numpy_adam_reference(x0, 3)), start=1):
    print(f"Adam step {t}  JAX: {ours}  NumPy: {ref}")
    assert np.allclose(ours, ref, rtol=1e-5, atol=1e-6)   # differences of about 1e-6 come from float32 rounding in 1-β2^t
assert int(adam_state.count) == 3
# The effect of bias correction: at step 1 every coordinate moves by about lr=0.1, regardless of the gradient size
assert np.allclose(np.abs(adam_traj[0]), 0.1, atol=1e-6)

mom_traj, _ = run_jax_optimizer(sgd_momentum(0.1, momentum=0.9), 3)
for ours, ref in zip(mom_traj, numpy_momentum_reference(x0, 3)):
    assert np.allclose(ours, ref, rtol=1e-5, atol=1e-6)
print("The first 3 steps of Adam and of momentum SGD are allclose to the NumPy reference ✓")
Adam step 1  JAX: [ 0.09999934 -0.09999935  0.09999935]  NumPy: [ 0.1 -0.1  0.1]
Adam step 2  JAX: [ 0.19989564 -0.19958612  0.19983187]  NumPy: [ 0.19989732 -0.19958776  0.19983351]
Adam step 3  JAX: [ 0.29961547 -0.29841074  0.29937363]  NumPy: [ 0.2996185 -0.2984137  0.2993766]
The first 3 steps of Adam and of momentum SGD are allclose to the NumPy reference ✓
def make_train(optimizer, num_steps):
    """Return a jit-compiled training function. Parameters and data are passed in as function arguments (no large arrays captured by closures);
    (params, opt_state) is the scan carry."""
    @jax.jit
    def train(params, x, y):
        def train_step(carry, _):
            params, opt_state = carry
            loss, grads = jax.value_and_grad(loss_fn)(params, x, y)   # x, y are arguments of train (tracers)
            updates, opt_state = optimizer.update(grads, opt_state, params)
            params = apply_updates(params, updates)
            return (params, opt_state), loss          # carry = state; y = per-step loss

        opt_state = optimizer.init(params)
        (params, opt_state), losses = lax.scan(train_step, (params, opt_state), None, length=num_steps)
        return params, opt_state, losses
    return train

results = {}
for name, opt in [("SGD", sgd_momentum(0.01, momentum=0.0)),
                  ("SGD+momentum", sgd_momentum(0.01, momentum=0.9)),
                  ("Adam", adam(0.01))]:
    p, s, losses = make_train(opt, 1000)(params0, X, Y)
    results[name] = losses
    print(f"{name:12s} initial loss {losses[0]:.4f} → final loss {losses[-1]:.5f}")

print("Step count in the Adam state:", s.count, "; first-moment structure matches the parameters:",
      jax.tree.structure(s.mu) == jax.tree.structure(p))
SGD          initial loss 0.2270 → final loss 0.00549
SGD+momentum initial loss 0.2270 → final loss 0.00087
Adam         initial loss 0.2270 → final loss 0.00003
Step count in the Adam state: 1000 ; first-moment structure matches the parameters: True
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['PingFang SC', 'Heiti SC', 'Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False

fig, ax = plt.subplots(figsize=(6, 3.5))
for name, losses in results.items():
    ax.plot(losses, label=name)
ax.set_yscale('log'); ax.set_xlabel('step'); ax.set_ylabel('MSE loss (log scale)')
ax.set_title('Same model, three kinds of optimizer state'); ax.legend()
plt.tight_layout(); plt.show()
No description has been provided for this image

🔍 Extra: donate_argnums, letting the new state reuse the old state's memory

⚪ Optional: read this when memory is tight (training large models on GPU/TPU); the gains on CPU are small.

How it works. Explicit state passing has a hidden cost: when you run new_state = step(state), the old state and the new state take up memory at the same time. JAX arrays are immutable, so by default XLA has to allocate separate buffers for the outputs. jax.jit(f, donate_argnums=...) (or donate_argnames=) tells XLA: "I won't need these inputs after the call." XLA can then reuse the input buffers directly as output buffers with the same shape and dtype (input/output aliasing), which amounts to an in-place update inside the compiled program.

When it helps. Mainly for saving GPU/TPU memory. When training a large model, the parameters plus Adam's two moments take about 3 times the parameter count. Without donation, the peak memory of a training step needs a whole extra copy of the "new state" on top of that, which can push you straight into OOM. With donation, the old state's buffers are reused by the new state, peak memory drops noticeably, and you also skip the allocation and copying. On CPU, memory is usually plentiful, so the gain is small.

What actually happens on CPU (JAX 0.12).

  • Donation takes effect: after the call, the donated array's is_deleted() is True, and accessing it again raises RuntimeError: Array has been deleted;
  • If a donation can't be used (no output array with a matching shape and dtype), there's no error, just a UserWarning: Some donated buffers were not usable.

The pitfall of donating the same buffer twice. If the same array object appears twice in the donated PyTree, donation raises INVALID_ARGUMENT: Attempt to donate the same buffer twice. In the training step below, the exception type is ValueError; in simpler calls we've also seen JaxRuntimeError, so don't catch just one type. The easiest place to hit this is an optimizer's init: taking a shortcut like zeros = jax.tree.map(jnp.zeros_like, params); AdamState(mu=zeros, nu=zeros) works fine without donation and errors as soon as you donate. The fix is to call jax.tree.map(jnp.zeros_like, params) separately for mu and nu, which is exactly how the adam above is written. We actually ran into this pitfall while writing this tutorial.

Rules of thumb: write the training step as state = step(state, batch), donate state (parameters + optimizer state), and overwrite the old reference with the return value right after the call; don't donate data you reuse every step; create every state buffer separately in init.

opt = adam(1e-2)

@partial(jax.jit, donate_argnums=(0, 1))     # donate params and opt_state; don't donate the data x, y
def train_step_donated(params, opt_state, x, y):
    loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
    updates, opt_state = opt.update(grads, opt_state, params)
    return apply_updates(params, updates), opt_state, loss

params = jax.tree.map(jnp.copy, params0)
opt_state = opt.init(params)
old_leaf = params[0]['w']

params, opt_state, loss = train_step_donated(params, opt_state, X, Y)   # correct: overwrite the old references
print("Old parameter buffer has been deleted:", old_leaf.is_deleted())
assert old_leaf.is_deleted()
deleted_error = None
try:
    print(old_leaf + 1)
except RuntimeError as e:
    deleted_error = e
    print("Accessing the old array again → RuntimeError:", e)
assert deleted_error is not None
assert not X.is_deleted()                    # data that wasn't donated is still usable

for _ in range(200):
    params, opt_state, loss = train_step_donated(params, opt_state, X, Y)
print(f"Loss after 200 steps: {loss:.5f}")

# A donation that can't be used: no output array of the same shape → a warning, not an error
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    jax.jit(lambda a: a.sum(), donate_argnums=0)(jnp.ones(10))
msgs = [str(m.message) for m in w]
print("Warnings:", [m[:60] for m in msgs])
assert any("donated buffers were not usable" in m for m in msgs)
Old parameter buffer has been deleted: True
Accessing the old array again → RuntimeError: Array has been deleted with shape=float32[1,32].
Loss after 200 steps: 0.00209
Warnings: ['Some donated buffers were not usable: float32[10].\nSee an ex']
# ❌ Wrong (same buffer donated twice): Adam's mu and nu share the same zeros object
def adam_init_shared(params):
    zeros = jax.tree.map(jnp.zeros_like, params)
    return AdamState(count=jnp.zeros([], jnp.int32), mu=zeros, nu=zeros)   # mu and nu are the very same arrays

params = jax.tree.map(jnp.copy, params0)
bad_opt_state = adam_init_shared(params)
double_donate_error = None
try:
    train_step_donated(params, bad_opt_state, X, Y)
except Exception as e:
    double_donate_error = e
    print(f"Actual result → {type(e).__name__}: {str(e)[:120]}")
assert double_donate_error is not None and "donate the same buffer twice" in str(double_donate_error)
# (XLA also prints a C++ log line with the same content to stderr; you can ignore it)
Actual result → ValueError: INVALID_ARGUMENT: Attempt to donate the same buffer twice in Execute() (flattened argument 13, replica 0, partition 0, f
E1114 21:15:13.484526 7099938 common_pjrt_client.cc:3073] ExecutePrepareWithOomRetries failed: INVALID_ARGUMENT: Attempt to donate the same buffer twice in Execute() (flattened argument 13, replica 0, partition 0, first use: 7). Toy example for this bug: `f(donate(a), donate(a))`.
=== Source Location Trace: ===
external/xla+/xla/util.h:304
external/xla+/xla/pjrt/common_pjrt_client.cc:1997
external/xla+/xla/pjrt/common_pjrt_client.cc:2833

# ✅ Fix: create mu and nu separately (the way adam(...).init above does it)
params = jax.tree.map(jnp.copy, params0)
good_opt_state = opt.init(params)
assert good_opt_state.mu[0]['w'] is not good_opt_state.nu[0]['w']
params, good_opt_state, loss = train_step_donated(params, good_opt_state, X, Y)
print(f"Independent buffers + donation: runs fine, loss = {loss:.5f}")
Independent buffers + donation: runs fine, loss = 0.22700

6.7 Combining state management with transformations

JAX manages state functionally: pass the state in as an argument and return the updated state, so the function stays pure. That's the key to whether it works with transformations. This section shows, one at a time, how the pattern works with jit, grad, vmap and pmap.

State management and JIT compilation

The counter's state is a dict {'count': ...} (which is itself a PyTree). Calling it twice by hand and calling it twice after jit should give exactly the same result.

To compare two PyTrees leaf by leaf for equality, use jax.tree.all(jax.tree.map(jnp.array_equal, a, b)).

⚠️ Note: older code often compares with jax.tree_util.tree_all(jax.tree_map(...)). In JAX 0.12, the top-level alias jax.tree_map has been removed and raises AttributeError: module 'jax' has no attribute 'tree_map'. Use jax.tree.map instead (see the migration table in 6.4).

def stateful_counter(count_state, increment):
    """Take the counter state and an increment, and return the new state."""
    new_count = count_state['count'] + increment
    return {'count': new_count}

initial_state = {'count': jnp.array(0)}

# Without jit
state = stateful_counter(initial_state, 1)
state = stateful_counter(state, 2)
print("Result of manual calls:", state)

# With jit
jit_counter = jax.jit(stateful_counter)
jit_state = jit_counter(initial_state, 1)
jit_state = jit_counter(jit_state, 2)
print("Result of JIT calls:", jit_state)
Result of manual calls: {'count': Array(3, dtype=int32, weak_type=True)}
Result of JIT calls: {'count': Array(3, dtype=int32, weak_type=True)}
# ❌ Old style (outdated): the top-level alias jax.tree_map was removed in JAX 0.12
tree_map_error = None
try:
    assert jax.tree_util.tree_all(jax.tree_map(lambda x, y: jnp.array_equal(x, y), state, jit_state))
except AttributeError as e:
    tree_map_error = e
    print("Actual result → AttributeError:", e)
assert tree_map_error is not None
Actual result → AttributeError: module 'jax' has no attribute 'tree_map'
# ✅ Recommended
assert jax.tree.all(jax.tree.map(lambda x, y: jnp.array_equal(x, y), state, jit_state))
print("jax.tree.map assertion passed: both results match")
jax.tree.map assertion passed: both results match

Key point: what jit traces depends on the structure of the state, plus the types and shapes of the arguments. As long as the structure stays the same, the compilation cache can be reused.

Tip: if the structure of the state changes between two calls (say you add a key to the dict), it triggers recompilation.

traces = []
@jax.jit
def bump(state):
    traces.append(jax.tree.structure(state))     # recorded during tracing
    return jax.tree.map(lambda x: x + 1, state)

bump({'count': jnp.array(0)})
bump({'count': jnp.array(5)})                     # same structure: cache hit
bump({'count': jnp.array(0), 'steps': jnp.array(0)})   # new key: retrace
print("Number of traces:", len(traces))
for t in traces: print("  ", t)
assert len(traces) == 2
Number of traces: 2
   PyTreeDef({'count': *})
   PyTreeDef({'count': *, 'steps': *})

State management and automatic differentiation

Take a linear model $\hat y = w x + b$ as an example; the parameter dict params is the state.

  • predict_and_update(params, x): returns the predictions plus the (unmodified) parameter state, to show the "state in, state out" function signature;
  • loss_fn(params, x, y_target): mean squared error $L = \frac{1}{n}\sum_i (\hat y_i - y_i)^2$; it returns only the scalar loss, so that grad can differentiate it;
  • jax.grad(loss_fn, argnums=0): the gradient with respect to argument 0 (the whole params PyTree), with the same structure as params;
  • jax.value_and_grad: gets the loss value and the gradient in one go.
def predict_and_update(params, x):
    """Forward prediction; the parameter state is returned as is (not modified here)."""
    pred = params['w'] * x + params['b']
    return pred, params

def loss_fn_linear(params, x, y_target):
    pred, _ = predict_and_update(params, x)
    return jnp.mean((pred - y_target) ** 2)

# Data: the ideal parameters are w=2.5, b=1.0
x_data = jnp.array([1.0, 2.0, 3.0])
y_data = jnp.array([3.5, 5.5, 7.5])
params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}

grad_fn_linear = jax.grad(loss_fn_linear, argnums=0)
grads = grad_fn_linear(params, x_data, y_data)
print("Gradient (same structure as params):", grads)

loss_val, grads = jax.value_and_grad(loss_fn_linear)(params, x_data, y_data)
print("Loss:", loss_val, " gradient:", grads)
Gradient (same structure as params): {'b': Array(-1., dtype=float32, weak_type=True), 'w': Array(-2., dtype=float32, weak_type=True)}
Loss: 0.25  gradient: {'b': Array(-1., dtype=float32, weak_type=True), 'w': Array(-2., dtype=float32, weak_type=True)}

Combine "compute the gradient + update the parameters" into one training step: old parameters in, loss and new parameters out. That's one purely functional state update. Use jax.tree.map for the leaf-by-leaf update.

⚠️ Note: if training_step updated the parameters with jax.tree_map, it would raise AttributeError in JAX 0.12 (the same problem as the comparison code above), so here it's written directly as jax.tree.map.

def training_step(params, x, y_target, learning_rate):
    loss, grads = jax.value_and_grad(loss_fn_linear)(params, x, y_target)
    updated_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, grads)
    return loss, updated_params

learning_rate = 0.1
current_params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
for i in range(2):
    loss, current_params = training_step(current_params, x_data, y_data, learning_rate)
    print(f"Step {i + 1}: loss = {loss:.4f}, parameters = {jax.tree.map(float, current_params)}")
# Check by hand: the step-1 gradient is w:-2, b:-1, so with learning rate 0.1 → w=2.2, b=1.1
assert jnp.allclose(current_params['w'], 2.1733334) and jnp.allclose(current_params['b'], 1.1)
Step 1: loss = 0.2500, parameters = {'b': 1.100000023841858, 'w': 2.200000047683716}
Step 2: loss = 0.0267, parameters = {'b': 1.100000023841858, 'w': 2.1733334064483643}

State management and vmap

vmap can run the same stateful function in parallel over "a batch of independent states". Below we maintain 3 counters at once (all starting at 0, with increments 1, 5 and 10), then do a second round of increments [2, 3, 4].

What in_axes=({'count': 0}, 0) means: the 'count' leaf of the state dict is mapped along axis 0, and the increments are mapped along axis 0 too. in_axes itself can be written as a PyTree with the same structure as the inputs.

batched_counter = jax.vmap(stateful_counter, in_axes=({'count': 0}, 0))

batch_state = {'count': jnp.array([0, 0, 0])}
batch_state = batched_counter(batch_state, jnp.array([1, 5, 10]))
print("After round 1:", batch_state)
batch_state = batched_counter(batch_state, jnp.array([2, 3, 4]))
print("After round 2:", batch_state)
After round 1: {'count': Array([ 1,  5, 10], dtype=int32)}
After round 2: {'count': Array([ 3,  8, 14], dtype=int32)}

Another common case is parameters shared across the batch, with only the data having a batch dimension. in_axes=(None, 0) means the first argument (params) isn't mapped, and x is mapped along axis 0. out_axes defaults to 0, so the returned parameter state gets "copied" into a version with a batch dimension.

batched_predict = jax.vmap(predict_and_update, in_axes=(None, 0))
preds, out_params = batched_predict(current_params, x_data)
print("Batched predictions:", preds)
print("Output parameter state (copied along axis 0):", out_params)
Batched predictions: [3.2733335 5.4466667 7.6200004]
Output parameter state (copied along axis 0): {'b': Array([1.1, 1.1, 1.1], dtype=float32, weak_type=True), 'w': Array([2.1733334, 2.1733334, 2.1733334], dtype=float32, weak_type=True)}

🔍 Extra: use out_axes to avoid needlessly copying shared state

🟢 Must-read

Look at the output above: the unbatched params were broadcast to length-3 arrays under the default out_axes=0, meaning one copy per example. With many parameters, this wastes memory, and it can also mislead later code into thinking these are "different parameters for each example". For outputs that don't vary with the batch, specifying out_axes=(0, None) returns them as they are (provided the output really doesn't depend on the batch dimension; otherwise it raises an error).

Also, if a loss function needs to return both a scalar loss and an updated state (such as BatchNorm's running statistics), use jax.value_and_grad(f, has_aux=True): f returns (loss, aux), only loss is differentiated, and aux is carried out as is. This is the standard way to "carry state through grad".

batched_predict2 = jax.vmap(predict_and_update, in_axes=(None, 0), out_axes=(0, None))
preds2, out_params2 = batched_predict2(current_params, x_data)
print("Parameter output with out_axes=(0, None):", out_params2)

# has_aux: carry a piece of "state" out alongside the loss
def loss_with_stats(params, x, y, stats):
    pred = params['w'] * x + params['b']
    new_stats = {'pred_mean': 0.9 * stats['pred_mean'] + 0.1 * pred.mean(),   # running-average statistic
                 'calls': stats['calls'] + 1}
    return jnp.mean((pred - y) ** 2), new_stats

stats = {'pred_mean': jnp.array(0.0), 'calls': jnp.array(0)}
(loss, stats), grads = jax.value_and_grad(loss_with_stats, has_aux=True)(current_params, x_data, y_data, stats)
print("Loss:", loss, "\nNew statistics:", stats, "\nGradient:", grads)
Parameter output with out_axes=(0, None): {'b': Array(1.1, dtype=float32, weak_type=True), 'w': Array(2.1733334, dtype=float32, weak_type=True)}
Loss: 0.022874068 
New statistics: {'calls': Array(1, dtype=int32, weak_type=True), 'pred_mean': Array(0.5446667, dtype=float32)} 
Gradient: {'b': Array(-0.10666626, dtype=float32, weak_type=True), 'w': Array(0.0177787, dtype=float32, weak_type=True)}

State management and pmap

With multiple devices, there are two common ways to place state:

  • Replicated state: things like model parameters get a full copy on every device (corresponding to in_axes=None, or stacking copies along the device dimension when passing them in);
  • Sharded state: state tied to the data is split across devices (in_axes=0).

After computing gradients in parallel, use collective operations like lax.psum and lax.pmean to aggregate across devices, so the parameters on every device stay identical. See the pmap chapter for details.

🔍 Extra: pmap still works in current JAX, but the officially recommended new approach is jax.jit with jax.sharding (NamedSharding + PartitionSpec) for automatic parallelism, or jax.shard_map for explicit per-device programming. For state, "replicated / sharded" corresponds to PartitionSpec() (fully replicated) and PartitionSpec('data') (split along the data axis). This machine has only 1 CPU device, so we only cover the concepts here; for a full demonstration with 8 simulated devices and the key points of migrating from pmap to shard_map, see Chapter 5.

Combining several transformations

training_step is a pure function, so it can be jitted directly. The second call uses the compilation cache, so it's faster. You can then wrap it further with vmap (say, to train several sets of hyperparameters at once), or plug it into pmap / sharded parallelism.

Summary: explicit state passing + PyTrees let stateful computations work smoothly with jit, grad, vmap and pmap.

import time
jit_training_step = jax.jit(training_step)

current_params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
for i in range(2):
    t0 = time.perf_counter()
    loss, current_params = jit_training_step(current_params, x_data, y_data, learning_rate)
    loss.block_until_ready()                     # wait for the asynchronous computation to finish before timing
    dt = (time.perf_counter() - t0) * 1e3
    print(f"Step {i + 1}: loss = {loss:.4f}, parameters = {jax.tree.map(float, current_params)}, time {dt:.2f} ms")
print("(the first call includes tracing + compilation; the second uses the cache directly)")

# Combining further: vmap trains one step with each of 4 different learning rates at once
lrs = jnp.array([0.01, 0.05, 0.1, 0.2])
init = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
losses, new_params = jax.jit(jax.vmap(training_step, in_axes=(None, None, None, 0)))(init, x_data, y_data, lrs)
print("w after one step with each of the 4 learning rates:", new_params['w'])
Step 1: loss = 0.2500, parameters = {'b': 1.100000023841858, 'w': 2.200000047683716}, time 29.01 ms
Step 2: loss = 0.0267, parameters = {'b': 1.100000023841858, 'w': 2.1733334064483643}, time 0.26 ms
(the first call includes tracing + compilation; the second uses the cache directly)
w after one step with each of the 4 learning rates: [2.02 2.1  2.2  2.4 ]

6.8 Practice: implementing stateful functions

The three exercises below all follow the same pattern: a pure function takes the current state and returns the new state (plus optional auxiliary outputs).

Exercise 1: a stateful counter with JIT

Define update_counter(count, increment) that returns (new_count, increment), that is, the new state plus an auxiliary output. Wrap it with jax.jit and loop 5 times, passing in the current count and the loop index i as the increment each time.

Expected output: the initial count is 0; at steps 1–5 the new count is 1–5, and the returned increment is 0–4. In other words, the count here always goes up by 1, and the increment i you pass in is just returned unchanged as an auxiliary output, to demonstrate the "new state + auxiliary output" return form.

def update_counter(count, increment):
    """Add 1 to the count, and return the increment passed in this time as an auxiliary output."""
    new_count = count + 1
    return new_count, increment

jit_update_counter = jax.jit(update_counter)

count = 0
print("Initial count:", count)
for i in range(5):
    count, returned_inc = jit_update_counter(count, i)
    print(f"Step {i + 1}: new count = {count}, returned increment = {returned_inc}")
Initial count: 0
Step 1: new count = 1, returned increment = 0
Step 2: new count = 2, returned increment = 1
Step 3: new count = 3, returned increment = 2
Step 4: new count = 4, returned increment = 3
Step 5: new count = 5, returned increment = 4

Exercise 2: computing a simple moving average

Use the tuple (current_sum, count) as the state. update_moving_average(state, new_value) returns (new_state, current_average).

Expected output: for the data [2, 4, 6, 8, 10], the averages are 2.00, 3.00, 4.00, 5.00, 6.00 in turn, and the states are (2.0, 1), (6.0, 2), (12.0, 3), (20.0, 4), (30.0, 5). The JIT version is optional.

Use arrays with a fixed dtype for the initial state, such as (jnp.array(0.0, jnp.float32), jnp.array(0, jnp.int32)), so that it has exactly the same type as the updated state. If you use the Python scalars (0.0, 0), JAX treats them as weakly typed. After the first call, new_sum = 0.0 + a float32 array becomes a strongly typed float32, so the input signature changes on the second call and jit traces and compiles again. In a large model, that means tens of seconds or even minutes of extra compilation, with no error, so it's hard to notice.

⚠️ Note: if you use (0.0, 0) as the initial state and run the JIT version directly, processing 5 values traces 2 times; with fixed-dtype arrays it traces only once. Both versions give the same numbers.

def update_moving_average(state, new_value):
    current_sum, count = state
    new_sum = current_sum + new_value
    new_count = count + 1
    current_average = new_sum / new_count
    return (new_sum, new_count), current_average

data = jnp.array([2.0, 4.0, 6.0, 8.0, 10.0])
state = (0.0, 0)                     # without jit, a Python-scalar initial state is fine
averages = []
for v in data:
    state, avg = update_moving_average(state, v)
    averages.append(float(avg))
    print(f"new value {float(v):5.1f} → average {float(avg):.2f}, state = ({float(state[0])}, {int(state[1])})")
assert averages == [2.0, 3.0, 4.0, 5.0, 6.0]
new value   2.0 → average 2.00, state = (2.0, 1)
new value   4.0 → average 3.00, state = (6.0, 2)
new value   6.0 → average 4.00, state = (12.0, 3)
new value   8.0 → average 5.00, state = (20.0, 4)
new value  10.0 → average 6.00, state = (30.0, 5)
# ❌ Not recommended: the JIT version with Python scalars (0.0, 0) as the initial state compiles one extra time
ma_traces = []
def update_moving_average_counted(state, v):
    ma_traces.append(1)                  # runs only during tracing; used to count traces
    return update_moving_average(state, v)

jit_update_moving_average = jax.jit(update_moving_average_counted)
state = (0.0, 0)
for v in data:
    state, avg = jit_update_moving_average(state, v)
print("Final average, JIT version:", avg, "| traces =", len(ma_traces))
assert len(ma_traces) == 2 and float(avg) == 6.0
Final average, JIT version: 6.0 | traces = 2
# ✅ Fix: give the initial state the same dtypes as the updated state
ma_traces.clear()
jit_update_moving_average = jax.jit(update_moving_average_counted)
state = (jnp.array(0.0, jnp.float32), jnp.array(0, jnp.int32))
for v in data:
    state, avg = jit_update_moving_average(state, v)
print("Final average, JIT version:", avg, "| traces =", len(ma_traces))
assert len(ma_traces) == 1 and float(avg) == 6.0
Final average, JIT version: 6.0 | traces = 1

Why: jit's cache key includes each input's shape, dtype and weak-type flag. Python scalars are weakly typed, which counts as a different signature from a strongly typed float32 array. lax.scan is stricter here: a strongly typed carry whose dtype changes from one step to the next is an error right away (see the Extra in 6.5 on lax.scan's carry).

Exercise 3: a single gradient descent step

The objective is $f(x) = x^2$ with gradient $f'(x) = 2x$. gradient_descent_step(params, learning_rate) returns params - learning_rate * gradient. Start from the parameter 5.0 with learning rate 0.1, and loop for 5 steps.

Expected output: each step multiplies by $1 - 2\times0.1 = 0.8$, so 5.0 → 4.0000, 3.2000, 2.5600, 2.0480, 1.6384. In the JIT version, the learning rate should be passed in as an ordinary (dynamic) argument.

⚠️ Note: you'll often hear that "the learning rate is usually marked as a static argument (static_argnums=(1,) / static_argnames)". That runs fine while the learning rate stays fixed, but as soon as it changes, every new value triggers recompilation (shown in 6.6: 5 values, 5 recompiles). The learning rate is just a scalar that doesn't affect the program's structure, so it should be passed in as a dynamic argument.

def loss_fn_sq(x):
    return x ** 2

grad_fn_sq = jax.grad(loss_fn_sq)

def gradient_descent_step(params, learning_rate):
    gradient = grad_fn_sq(params)
    return params - learning_rate * gradient

params = jnp.array(5.0)
lr = 0.1
print(f"Initial parameter: {params}")
for step in range(5):
    params = gradient_descent_step(params, lr)
    print(f"Step {step + 1}: {params:.4f}")
assert jnp.allclose(params, 1.6384)

# JIT version: the learning rate as a dynamic argument
jit_gd_step = jax.jit(gradient_descent_step)
p = jnp.array(5.0)
for _ in range(5):
    p = jit_gd_step(p, lr)
print("JIT version after 5 steps:", p)
assert jnp.allclose(p, 1.6384)
Initial parameter: 5.0
Step 1: 4.0000
Step 2: 3.2000
Step 3: 2.5600
Step 4: 2.0480
Step 5: 1.6384
JIT version after 5 steps: 1.6383998

Exercise summary: pure functions + "state in, state out". With PyTrees, this pattern scales all the way from a simple counter to nested model parameters and optimizer state.


🏋️ Extra exercises (with reference solutions)

The exercises below get progressively harder. Try them yourself first, then look at the solutions.

Extra exercise 1: an exponential moving average (EMA) of the parameters

Training often keeps an EMA copy of the parameters (used for evaluation, since it's more stable): $\bar\theta \leftarrow d\,\bar\theta + (1-d)\,\theta$.

Requirements:

  1. Define TrainState(NamedTuple) with three fields: step, params and ema_params;
  2. Write a jit-compiled ema_train_step(state, x, y, lr, decay) that does one SGD step on params, updates ema_params at the same time, and adds 1 to step;
  3. Train for 100 steps on the linear regression data from 6.7, and print the final params and ema_params.

Self-check interface: TrainState (a NamedTuple with fields step, params, ema_params); ema_train_step(state, x, y, lr, decay): jit-compiled, uses loss_fn_linear from 6.7 as the loss, and returns a new TrainState.

# ✍️ Your answer: Extra exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Extra exercise 1
try:
    TrainState, ema_train_step
except NameError as e:
    print(f"⏳ Extra exercise 1: not answered yet: {e}")
else:
    def _selfcheck():
        import numpy as np
        assert {"step", "params", "ema_params"} <= set(getattr(TrainState, "_fields", ())), \
            "TrainState should be a NamedTuple with the three fields step, params and ema_params"
        assert hasattr(ema_train_step, "lower"), "ema_train_step should be a function compiled with jax.jit"
        x = jnp.array([0.5, -1.0, 2.0, 3.0]); y = jnp.array([1.0, 0.0, 4.0, 7.5])
        w0, b0, ew, eb, lr, d = 1.5, -0.5, 0.2, 0.3, 0.1, 0.8
        st = TrainState(step=jnp.array(3), params={"w": jnp.array(w0), "b": jnp.array(b0)},
                        ema_params={"w": jnp.array(ew), "b": jnp.array(eb)})
        new = ema_train_step(st, x, y, lr, d)
        assert isinstance(new, TrainState), "ema_train_step should return a new TrainState"
        assert int(new.step) == 4, f"step should go from 3 to 4, got {new.step}"
        # Closed-form gradient of the linear model's MSE
        xn, yn = np.asarray(x, np.float64), np.asarray(y, np.float64)
        r = w0 * xn + b0 - yn
        pw, pb = w0 - lr * 2 * np.mean(r * xn), b0 - lr * 2 * np.mean(r)
        got_p = [float(new.params["w"]), float(new.params["b"])]
        assert np.allclose(got_p, [pw, pb], atol=1e-5), f"params is not the result of one SGD step: expected {[pw, pb]}, got {got_p}"
        got_e = [float(new.ema_params["w"]), float(new.ema_params["b"])]
        ema_new = [d * ew + (1 - d) * pw, d * eb + (1 - d) * pb]      # using the updated parameters
        ema_old = [d * ew + (1 - d) * w0, d * eb + (1 - d) * b0]      # using the parameters before the update (both conventions are accepted)
        assert np.allclose(got_e, ema_new, atol=1e-5) or np.allclose(got_e, ema_old, atol=1e-5), \
            f"ema_params does not follow ē ← d·ē + (1−d)·θ: got {got_e}"
    _selfcheck(); del _selfcheck
    print("✅ Extra exercise 1: self-check passed")
⏳ Extra exercise 1: not answered yet: name 'ema_train_step' is not defined

Extra exercise 2: Welford's online variance with lax.scan + vmap

Welford's algorithm computes the mean and variance online with the state $(n, \mu, M_2)$, and it's numerically more stable than "sum the squares first, then subtract":

$$ n \leftarrow n+1,\quad \delta = x-\mu,\quad \mu \leftarrow \mu + \delta/n,\quad M_2 \leftarrow M_2 + \delta\,(x-\mu),\quad \sigma^2 = M_2/n $$

Requirements:

  1. Write welford_step(state, x) as the body function of lax.scan, outputting the current variance at each step;
  2. Write online_var(xs) to process one data stream and return the final mean, the variance, and the variance trace;
  3. Use jax.vmap to process 4 random data streams of length 1000 at once (generated with jax.random.key), and compare against jnp.mean / jnp.var.

Self-check interface: welford_step(state, x) (the body function of lax.scan); online_var(xs): takes a 1-D array and returns (mean, variance, variance trace), where the variance trace has the same shape as xs.

# ✍️ Your answer: Extra exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Extra exercise 2
try:
    welford_step, online_var
except NameError as e:
    print(f"⏳ Extra exercise 2: not answered yet: {e}")
else:
    def _selfcheck():
        import numpy as np
        rng = np.random.default_rng(2)
        xs = (1000.0 + 0.5 * rng.standard_normal(300)).astype(np.float32)     # large offset, small variance: a test of numerical stability
        assert "scan" in str(jax.make_jaxpr(online_var)(jnp.asarray(xs))), "online_var should be implemented with lax.scan"
        m, v, tr = jax.jit(online_var)(jnp.asarray(xs))
        x64 = xs.astype(np.float64)
        assert np.shape(tr) == (300,), f"The variance trace should have shape (300,), got {np.shape(tr)}"
        assert np.isclose(float(m), x64.mean(), rtol=1e-6), f"Wrong mean: {float(m)} vs {x64.mean()}"
        assert np.isclose(float(v), x64.var(), rtol=2e-3), \
            f"Variance {float(v):.6f} is too far from the float64 reference {x64.var():.6f} (did you use \"sum of squares minus squared mean\"?)"
        for k in (0, 1, 9, 99, 299):
            assert np.isclose(float(tr[k]), x64[:k + 1].var(), rtol=2e-3, atol=1e-6), \
                f"Entry {k} of the variance trace should be the variance of the first {k + 1} values"
        streams = (np.arange(1, 4)[:, None] * rng.standard_normal((3, 200)) + 50.0).astype(np.float32)
        mb, vb, tb = jax.vmap(online_var)(jnp.asarray(streams))
        assert np.shape(mb) == (3,) and np.shape(vb) == (3,) and np.shape(tb) == (3, 200), "Wrong output shapes after vmap"
        assert np.allclose(vb, streams.astype(np.float64).var(axis=1), rtol=2e-3), "Wrong per-stream variance after vmap"
    _selfcheck(); del _selfcheck
    print("✅ Extra exercise 2: self-check passed")
⏳ Extra exercise 2: not answered yet: name 'welford_step' is not defined

Extra exercise 3: implement RMSProp and plug it into the generic training framework above

RMSProp's state is a running average $s$ of the squared gradients:

$$ s \leftarrow \rho\, s + (1-\rho)\, g^2,\qquad \Delta\theta = -\eta\, \frac{g}{\sqrt{s}+\epsilon} $$

Requirements: following sgd_momentum / adam above, write rmsprop(lr, rho=0.9, eps=1e-8) that returns an Optimizer with init / update; then use make_train to train for 1000 steps on the $\sin(x)$ fitting task and compare the final loss with Adam's.

Self-check interface: rmsprop(lr, rho=0.9, eps=1e-8) returns Optimizer(init, update), where update(grads, state, params=None) returns (updates, new_state) and updates is the amount to add to the parameters; name the per-step losses from training with make_train rms_losses.

# ✍️ Your answer: Extra exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Extra exercise 3
try:
    rmsprop, rms_losses
except NameError as e:
    print(f"⏳ Extra exercise 3: not answered yet: {e}")
else:
    def _selfcheck():
        import numpy as np
        lr, rho, eps = 0.05, 0.8, 1e-6
        params = {"a": jnp.array([1.0, -2.0, 3.0]), "b": [jnp.ones((2, 2))]}
        g1 = {"a": jnp.array([0.5, -1.0, 2.0]), "b": [jnp.array([[1.0, -3.0], [0.1, 0.0]])]}
        g2 = {"a": jnp.array([-0.5, 0.2, 1.0]), "b": [jnp.array([[2.0, 1.0], [-0.1, 4.0]])]}
        opt = rmsprop(lr, rho=rho, eps=eps)
        st = opt.init(params)
        u1, st = opt.update(g1, st, params)
        u2, st = jax.jit(opt.update)(g2, st, params)          # it must also work inside jit
        assert jax.tree.structure(u1) == jax.tree.structure(params), "updates should have the same structure as the parameters"
        s1 = jax.tree.map(lambda g: (1 - rho) * np.asarray(g, np.float64) ** 2, g1)
        s2 = jax.tree.map(lambda s, g: rho * s + (1 - rho) * np.asarray(g, np.float64) ** 2, s1, g2)
        e1 = jax.tree.map(lambda g, s: -lr * np.asarray(g, np.float64) / (np.sqrt(s) + eps), g1, s1)
        e2 = jax.tree.map(lambda g, s: -lr * np.asarray(g, np.float64) / (np.sqrt(s) + eps), g2, s2)
        for name, got, exp in (("Step 1", u1, e1), ("Step 2", u2, e2)):
            ok = jax.tree.leaves(jax.tree.map(lambda a, b: bool(np.allclose(a, b, rtol=1e-5, atol=1e-6)), got, exp))
            assert all(ok), f"{name}: updates don't match the RMSProp formula (updates should carry a minus sign, and s must accumulate across steps)"
        rl = np.asarray(rms_losses)
        assert rl.shape == (1000,) and np.all(np.isfinite(rl)), "rms_losses should be 1000 steps of finite losses"
        assert rl[-1] < 0.5 * rl[0], f"The loss didn't drop noticeably after training: {rl[0]:.4f} → {rl[-1]:.4f}"
    _selfcheck(); del _selfcheck
    print("✅ Extra exercise 3: self-check passed")
⏳ Extra exercise 3: not answered yet: name 'rmsprop' is not defined

Extra exercise 4: an RNN's hidden state, and taking gradients through lax.scan

A recurrent neural network's hidden state $h_t = \tanh(W_h h_{t-1} + W_x x_t + b)$ is the textbook example of "state carried through a loop".

Requirements:

  1. Initialize the parameters {'Wh': (H,H), 'Wx': (D,H), 'b': (H,), 'Wo': (H,1)} (H=16, D=1) with jax.random.key;
  2. Write rnn_forward(params, xs): use lax.scan with the hidden state as the carry, outputting $h_t W_o$ at each step;
  3. Task: given a sequence of length 20, predict "the running sum up to the current position". Train for 300 steps with Adam (the one implemented above), and print the initial and final loss;
  4. Observe: jax.grad passes straight through lax.scan (which amounts to getting BPTT, backpropagation through time, for free).

Self-check interface: rnn_forward(params, xs), where params contains Wh (16,16), Wx (1,16), b (16,), Wo (16,1) and xs has shape (T, 1), returns shape (T, 1); name the per-step losses from 300 training steps rnn_losses.

# ✍️ Your answer: Extra exercise 4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Extra exercise 4
try:
    rnn_forward, rnn_losses
except NameError as e:
    print(f"⏳ Extra exercise 4: not answered yet: {e}")
else:
    def _selfcheck():
        import numpy as np
        from jax.test_util import check_grads
        rng = np.random.default_rng(4)
        P = {"Wh": 0.3 * rng.standard_normal((16, 16)), "Wx": rng.standard_normal((1, 16)),
             "b": 0.1 * rng.standard_normal(16), "Wo": 0.3 * rng.standard_normal((16, 1))}
        xs = rng.uniform(-1, 1, (7, 1))
        params = jax.tree.map(lambda a: jnp.asarray(a, jnp.float32), P)
        xs_j = jnp.asarray(xs, jnp.float32)
        assert "scan" in str(jax.make_jaxpr(rnn_forward)(params, xs_j)), "rnn_forward should be implemented with lax.scan"
        out = np.asarray(jax.jit(rnn_forward)(params, xs_j))
        assert out.shape == (7, 1), f"The output shape should be (T, 1) = (7, 1), got {out.shape}"
        def ref(row_vec):           # NumPy float64, unrolled step by step; both matrix-multiplication conventions are accepted
            h, ys = np.zeros(16), []
            for x in xs:
                pre = (h @ P["Wh"] if row_vec else P["Wh"] @ h) + x @ P["Wx"] + P["b"]
                h = np.tanh(pre)
                ys.append(h @ P["Wo"])
            return np.array(ys)
        assert np.allclose(out, ref(True), atol=1e-4) or np.allclose(out, ref(False), atol=1e-4), \
            "The output of rnn_forward doesn't match h_t = tanh(W_h h_{t-1} + W_x x_t + b), y_t = h_t W_o (h_0 should be 0)"
        batched = jax.vmap(rnn_forward, in_axes=(None, 0))(params, jnp.stack([xs_j, -xs_j, 2 * xs_j]))
        assert batched.shape == (3, 7, 1), "rnn_forward should be vmappable over a batch of sequences"
        f = lambda p: jnp.sum(rnn_forward(p, xs_j) ** 2)
        g = jax.grad(f)(params)
        assert jax.tree.structure(g) == jax.tree.structure(params), "The gradient should have the same structure as params"
        check_grads(f, (params,), order=1, modes=["rev"], atol=5e-2, rtol=5e-2)   # backprop through scan agrees with finite differences
        rl = np.asarray(rnn_losses)
        assert rl.shape == (300,) and np.all(np.isfinite(rl)), "rnn_losses should be 300 steps of finite losses"
        assert rl[-1] < 0.1 * rl[0], f"After 300 training steps the loss should drop below 1/10 of its initial value: {rl[0]:.4f} → {rl[-1]:.4f}"
    _selfcheck(); del _selfcheck
    print("✅ Extra exercise 4: self-check passed")
⏳ Extra exercise 4: not answered yet: name 'rnn_forward' is not defined

🔍 Extra: how ecosystem libraries manage state: Optax and Flax

⚪ Optional: read this when you're about to start using Optax / Flax.

The pattern we've written by hand in this chapter is exactly what the JAX ecosystem libraries are designed around. You can follow the comparison below without installing them (the code below is illustrative and wasn't run in this environment; check each library's current docs for exact signatures). As it happens, this environment does have optax 0.2.9 and flax 0.12.12 installed; for an actual cross-check of the hand-written optimizers against optax, see Chapter 7, topic 5.

Optax: optimizers as pure init / update functions

An Optax optimizer is just a pair of pure functions, with exactly the same structure as this chapter's Optimizer(init, update):

import optax

optimizer = optax.adam(learning_rate=1e-3)        # GradientTransformation(init, update)
opt_state = optimizer.init(params)                # the state is a PyTree

@jax.jit
def train_step(params, opt_state, batch):
    loss, grads = jax.value_and_grad(loss_fn)(params, batch)
    updates, opt_state = optimizer.update(grads, opt_state, params)   # passed in explicitly, returned explicitly
    params = optax.apply_updates(params, updates)
    return params, opt_state, loss

What Optax adds is composability: optax.chain(optax.clip_by_global_norm(1.0), optax.adamw(schedule)) strings together gradient clipping, weight decay and a learning rate schedule, and the combined state is a tuple of the parts' states (a PyTree again). The learning rate schedule is computed internally from the count state, which neatly avoids the "static learning rate causes recompilation" problem from 6.6.

Flax Linen: stateless modules, variables passed in and out explicitly

A Linen Module only describes the computation and holds no data. Parameters and other mutable collections (such as BatchNorm's batch_stats) live in a variables dict that is passed to apply explicitly:

variables = model.init(key, x)                    # {'params': ..., 'batch_stats': ...}
# Training mode: declare batch_stats mutable, and apply returns the updated state as its second return value
y, updates = model.apply(variables, x, train=True, mutable=['batch_stats'])
variables = {**variables, **updates}
# When randomness is needed (e.g. Dropout), the key is passed in explicitly too
y = model.apply(variables, x, rngs={'dropout': dropout_key})

This is a direct expression of this chapter's "state in, state out" pattern, and mutable=[...] plays the role of has_aux from the Extra in 6.7.

Flax NNX: object-oriented on the surface, pure functions underneath

NNX lets you write mutable objects PyTorch-style (self.count.value += 1), which feels more natural. It works with JAX transformations because wrappers like nnx.jit and nnx.grad use nnx.split(model) before entering the transformation to break the object into a static structure graphdef + a state PyTree state, rebuild it with nnx.merge inside the pure function, and write the new state back to the object afterward. In other words, "mutable" is just surface syntax; at the transformation boundary, it's still explicit state passing:

graphdef, state = nnx.split(model)     # structure + state PyTree
# ... inside the pure function: model = nnx.merge(graphdef, state); compute; _, state = nnx.split(model)
nnx.update(model, state)               # write the new state back to the object
ApproachWhere the state livesHow it's updated
Hand-written (this chapter)Your own dict / NamedTuplenew_state = f(state, ...)
OptaxThe opt_state PyTreeupdates, opt_state = tx.update(...)
Flax LinenThe variables dict (grouped into collections)apply(..., mutable=[...]) returns the updates
Flax NNXVariable attributes on the module objectWrapped transformations split / merge automatically

📝 Chapter 6 quiz

About this quiz: the course's official quiz is on the quiz page (login required). The 8 questions below focus on design trade-offs and common failure modes.

1. Which of these functions behaves differently under jax.jit than without jit?

  • A. def f(x): return jnp.sin(x) * 2
  • B. def f(x): global n; n += 1; return x + n
  • C. def f(x, s): return x * s
  • D. def f(p, g): return jax.tree.map(lambda a, b: a - 0.1 * b, p, g)

2. In JAX 0.12, which of these calls raises AttributeError?

  • A. jax.tree.map(f, t) · B. jax.tree_util.tree_map(f, t) · C. jax.tree_map(f, t) · D. jax.tree.leaves(t)

3. What is the result of jax.tree.leaves({'w': 1.0, 'b': None, 'c': [2.0, 3.0]})?

  • A. [None, 1.0, 2.0, 3.0] · B. [2.0, 3.0, 1.0] · C. [1.0, None, [2.0, 3.0]] · D. [1.0, 2.0, 3.0]

4. Inside a jitted function you call lax.scan(body, init, None, length=steps), where steps is an ordinary jit argument. What happens?

  • A. It runs normally · B. It raises ConcretizationTypeError · C. It quietly runs only 1 step · D. It automatically becomes a while_loop

5. You train with a cosine-decay learning rate and pass the learning rate to a jit-compiled training step via static_argnums. What's the main problem?

  • A. The gradients come out wrong · B. The learning rate doesn't take effect · C. Every step recompiles · D. It raises a type error

6. Which of these is the right way to handle random keys?

  • A. Reuse the same key throughout training to keep things reproducible
  • B. Keep the key in a global variable, split it inside the jitted function, and write it back to the global
  • C. Before each use, key, sub = jax.random.split(key), sample with sub, and keep passing key along as state
  • D. Create a fresh key each step with jax.random.key(step); this is exactly equivalent to split, with no downsides

7. Given step = jax.jit(update, donate_argnums=0), which usage causes an error?

  • A. state = step(state, g)
  • B. new = step(state, g); print(state)
  • C. state, aux = step(state, g) (update returns a pair)
  • D. Running state = step(state, g) repeatedly in a loop

8. A function needs to return both a scalar loss and updated BatchNorm statistics, and you want gradients of the loss only. What should you use?

  • A. jax.grad(f), with f returning a tuple · B. jax.value_and_grad(f, has_aux=True) · C. jax.vmap(jax.grad(f)) · D. Store the statistics in a global variable
Answer and explanation (click to expand)
  1. B. Modifying a global variable is a side effect that runs only once, during tracing, so n stops increasing on later cache hits; and the n read during tracing gets baked in as a constant. A and D are pure functions; in C, s is an argument, so there's no problem.
  2. C. The top-level alias jax.tree_map has been removed; both jax.tree.* and jax.tree_util.* work (verified in 6.4).
  3. B. This question tests two details: ① None is an empty PyTree node with no children, not a leaf; ② dicts are flattened in sorted key order, not insertion order. Sorted, the keys are b, c, w: 'b' is None and contributes no leaves; 'c' contributes 2.0, 3.0; 'w' contributes 1.0, so the result is [2.0, 3.0, 1.0]. Picking D means overlooking the key sorting. (The code cell below verifies this.)
  4. B. scan's length determines the output shape, so it must be known at compile time. The fix is static_argnames, or switching to fori_loop (shown in 6.5).
  5. C. The values of static arguments are part of the compilation cache key, and every new value triggers recompilation (shown in 6.6). Pass the learning rate in as a dynamic array argument instead, or compute it inside the function from the step count, the way Optax does.
  6. C. A makes all the "random numbers" the same; B's write to a global inside jit only takes effect during tracing; D uses consecutive integers as seeds, which isn't recommended statistically; the right ways to derive keys are split or jax.random.fold_in(key, step).
  7. B. After state is donated, its buffer has been deleted, and accessing it again raises RuntimeError: Array has been deleted.
  8. B. has_aux=True lets f return (loss, aux); only loss is differentiated, and aux is carried out as is (the Extra in 6.7).
# Verify quiz questions 2, 3 and 4
print("Q2:", "jax.tree_map exists" if hasattr(jax, "tree_map") else "jax.tree_map does not exist → AttributeError")
print("Q3:", jax.tree.leaves({'w': 1.0, 'b': None, 'c': [2.0, 3.0]}))

@jax.jit
def q4(init, steps):
    return lax.scan(lambda c, _: (c + 1, None), init, None, length=steps)[0]
try:
    q4(0, 5)
except Exception as e:
    print("Q4:", type(e).__name__)
Q2: jax.tree_map does not exist → AttributeError
Q3: [2.0, 3.0, 1.0]
Q4: ConcretizationTypeError

Version notes

Below is a summary of the patterns and conclusions in this chapter that need attention under JAX 0.12. Each one is verified with an assert at the corresponding place in the text (environment: JAX 0.12.0.1, CPU).

No.SectionKey pointWhere
16.2Jitting a plain object that modifies its attributes in place (like StateHolder) always raises TypeError (the object is neither an array nor a PyTree); the case that silently goes wrong is after registering it as a PyTree6.2 The challenge of state in functional code
26.4For leaf-by-leaf updates, jax.tree.map(...) is recommended; jax.tree_util.tree_map still works but is no longer recommended6.4 Using PyTrees for structured state
36.4vmap stands for vectorization; quantization is a different thing6.4 Using PyTrees for structured state
46.4A full old → new migration table for the PyTree API (marking the top-level aliases removed in 0.12), with demos of map / leaves / structure / flatten / unflatten / map_with_path / reduce / all / transpose6.4 Using PyTrees for structured state (Extra: migration table)
56.4Besides dict / list / tuple, state containers can be NamedTuple, register_dataclass or register_pytree_node_class6.4 Using PyTrees for structured state (Extra: custom containers)
66.5A tracer is not a type the caller passes in; a jitted counter called starting from a Python int traces only once, and an extra trace happens only when a weak type turns into a strong type6.5 Example: a stateful counter
76.5Passing an ordinary jit argument to scan(length=steps) raises ConcretizationTypeError; fix: static_argnames="steps", or switch to lax.fori_loop6.5 Example: a stateful counter
86.5A random key is state that must be passed explicitly: use each key only once and split first; use jax.random.key instead of PRNGKey (demos and asserts in Chapter 1 and Advanced 10)6.5 Example: a stateful counter (Extra: random keys)
96.6On $f(x)=(x-3)^2$, momentum with $\eta=0.1,\ \mu=0.9$ ends at 4.0478 after 20 steps, worse than plain SGD's 2.9654; with $\mu=0.5$ it reaches 2.99926.6 Example: simple optimizer state
106.6Making the learning rate and momentum static_argnums triggers a recompile for every new value: 5 learning rate values, 5 recompiles; as dynamic arguments it compiles only once6.6 Example: simple optimizer state
116.6Generic PyTree momentum SGD and Adam (with bias correction), allclose to a NumPy reference; plus a full training loop (term-by-term comparison and actual cross-check with Optax in Chapter 7, topic 5)6.6 Example: simple optimizer state (Extra: generic optimizers)
126.6Reducing the memory cost of state updates with donate_argnums: how it works, actual behavior on CPU, and the pitfall of donating the same buffer twice (Adam's mu and nu must not share zeros)6.6 Example: simple optimizer state (Extra: donate_argnums)
136.7Compare state before and after jit with jax.tree.all(jax.tree.map(...)); the top-level alias jax.tree_map has been removed (AttributeError)6.7 Combining state management with transformations
146.7Use jax.tree.map for leaf-by-leaf parameter updates in training_step (jax.tree_map has been removed)6.7 Combining state management with transformations
156.7When vmap shares parameters, the default out_axes=0 copies them along the batch dimension; avoid that with out_axes=(0, None); use has_aux when you need to carry state out6.7 Combining state management with transformations
166.7The new way to manage state across multiple devices is jit + jax.sharding / jax.shard_map (pmap still works)6.7 Combining state management with transformations
176.8If the JIT version of Exercise 2 uses the Python scalars (0.0, 0) as the initial state, weak typing makes it trace 2 times; with fixed-dtype arrays it traces only once6.8 Practice: implementing stateful functions
186.8In Exercise 3 the learning rate should be passed in as a dynamic argument, not a static one (same reason as item 10)6.8 Practice: implementing stateful functions
196.1Writing a global variable inside jit happens only during tracing, and reading one freezes it as a constant; use jax.debug.print for run-time printing6.1 Functional purity and side effects
20Quiz8 quiz questions for this chapter, focused on design trade-offs and common failures, with answers and explanationsChapter 6 quiz
216.1 / 6.2vmap traces only once, so a side effect that modifies external state in the function runs once for the whole batch and sees a BatchTracer; the problem isn't race conditions but that side effects don't happen per example (assert)6.2 The challenge of state in functional code

📌 Summary

  • Pure function = deterministic + no side effects. JAX's jit / grad / vmap / pmap are all built on this assumption.
  • Side effects inside jit (print, writing globals) run only once, during tracing; globals that are read get frozen as constants. Use jax.debug.print for debugging.
  • Modifying object attributes in place under jit either errors (the object isn't a PyTree) or silently goes wrong (the object is a PyTree, and only an internal copy gets modified). JAX arrays themselves are immutable; use .at[].set().
  • Explicit state passing: new_state, out = f(state, inputs); the caller is responsible for catching the new state and passing it to the next call.
  • Organize complex state with PyTrees (dict / list / tuple / NamedTuple / registered dataclass), and transformations keep the structure automatically; the result of grad has the same structure as the parameters.
  • Use jax.tree.* (recommended) or jax.tree_util.*; top-level aliases like jax.tree_map have been removed.
  • In lax.scan, the carry is the state and ys collects per-step outputs; length must be static; the carry's structure, shapes and dtypes must stay the same at every step.
  • A random key is state too: use each key only once; after a split, pass one along and use the other.
  • Optimizer state (momentum, Adam's moments) is a PyTree with the same structure as the parameters; the init / update interface is the core design of Optax.
  • Don't make changing scalars like the learning rate static arguments, or every new value recompiles; give the initial state the same dtypes as the updated state to avoid an extra compile.
  • donate_argnums lets the new state reuse the old state's buffers, lowering peak memory; after donating, you can't access the old arrays again.
  • vmap can batch over independent states (in_axes can be a PyTree); for shared state use in_axes=None, out_axes=None. Use has_aux=True when you need to carry extra state out.

🗂️ Cheat sheet

APIWhat it does
jax.tree.map(f, t, *rest)Apply a function leaf by leaf and return a new tree with the same structure
jax.tree.leaves(t)Pull out all the leaves (a flat list)
jax.tree.structure(t)Get the tree structure, a PyTreeDef
jax.tree.flatten(t) / jax.tree.unflatten(d, l)Flatten into (leaves, structure) / rebuild from structure and leaves
jax.tree.all(t) / jax.tree.reduce(f, t)Logical AND over the leaves / reduce the leaves
jax.tree.map_with_path(f, t)map with key paths; jax.tree_util.keystr turns a path into a string
jax.tree_util.register_dataclassRegister a dataclass as a PyTree (distinguishing data fields from static fields)
jax.tree_util.register_pytree_node_classFlatten / rebuild rules for a custom class
typing.NamedTupleA ready-to-use PyTree state container with named fields; ._replace() creates a new copy
x.at[idx].set(v)Functional "modification" of an array; returns a new array
jax.lax.scan(body, init, xs, length)A loop with state, body(carry, x) -> (carry, y)
jax.lax.fori_loop(lo, hi, body, init)A loop whose count can be decided at run time (returns only the final state)
jax.random.key(seed) / jax.random.split(key, n)Create a key / derive new keys (keys are explicit state)
jax.random.fold_in(key, i)Derive a key from an integer index
jax.value_and_grad(f, has_aux=True)Get the loss, auxiliary state and gradients together
jax.jit(f, static_argnums/static_argnames)Make arguments that affect the program structure static (every new value recompiles)
jax.jit(f, donate_argnums=...)Donate input buffers to the outputs, saving memory on state updates
jax.vmap(f, in_axes, out_axes)Batch over independent states; None means shared
jax.debug.print(fmt, *args)A print that runs on every execution of the compiled function

🧭 Course summary and where to go next

After the 6 chapters of this course, you have the core mental model of JAX:

  1. jax.Array and jax.numpy: immutable arrays, a NumPy-like API, running the same way on CPU / GPU / TPU;
  2. jit: tracing → Jaxpr → XLA compilation, static vs. dynamic arguments, and what triggers recompilation;
  3. grad: functional automatic differentiation, value_and_grad, has_aux, higher-order derivatives;
  4. vmap: automatic vectorization, in_axes / out_axes;
  5. pmap and parallelism: multi-device data parallelism and collective communication;
  6. State management: pure functions + explicit state passing + PyTrees, which combine all the transformations above into a complete training pipeline.

Suggested next steps

StageContentNotes
Must-readJAX - The Sharp BitsThe official "gotchas" collection: pure functions, in-place updates, out-of-bounds indexing, random numbers, control flow, NaN debugging and more; many of this chapter's Extras come from here
Official tutorialsStateful computations, Pytrees, Control flow and Pseudorandom numbers in the JAX docsThe authoritative version of this chapter's content
OptimizersOptaxComposable gradient transformations: Adam / AdamW / Lion, gradient clipping, learning rate schedules
Neural networksFlax (NNX recommended for new projects)Modular modeling; understand how NNX's split / merge relates to this chapter
CheckpointsOrbaxSave / restore PyTrees of parameters and optimizer state, with async and sharding support
ParallelismThe official Distributed arrays and automatic parallelization, jax.sharding, shard_mapThe new generation of parallelism, gradually replacing pmap
Performancejax.profiler, jax.make_jaxpr, donate_argnums, jax.checkpoint (rematerialization)Find compilation and memory bottlenecks
Hands-onWrite a complete MNIST / CIFAR training script with Flax + Optax + OrbaxBundle the parameters, optimizer state, random key and step count into one TrainState PyTree

Appendix: reference solutions

Write your own answer in the "✍️ Your answer" cell in the main text first, and check it with the "🧪 Self-check". Come here to compare once the self-check passes or you're really stuck. Running the solutions below in order overwrites any functions with the same names that you defined in the answer cells.

Extra exercise 1

See "Extra exercise 1: an exponential moving average (EMA) of the parameters" in the main text for the problem.

# Reference solution
class TrainState(NamedTuple):
    step: jax.Array
    params: dict
    ema_params: dict

@jax.jit
def ema_train_step(state, x, y, lr, decay):
    grads = jax.grad(loss_fn_linear)(state.params, x, y)
    new_params = jax.tree.map(lambda p, g: p - lr * g, state.params, grads)
    new_ema = jax.tree.map(lambda e, p: decay * e + (1 - decay) * p, state.ema_params, new_params)
    return TrainState(step=state.step + 1, params=new_params, ema_params=new_ema)

p0 = {'w': jnp.array(0.0), 'b': jnp.array(0.0)}
state = TrainState(step=jnp.array(0), params=p0, ema_params=p0)
for _ in range(100):
    state = ema_train_step(state, x_data, y_data, 0.05, 0.9)
print("step      :", state.step)
print("params    :", jax.tree.map(lambda a: round(float(a), 4), state.params))
print("ema_params:", jax.tree.map(lambda a: round(float(a), 4), state.ema_params))
step      :
 100
params    : {'b': 1.3449, 'w': 2.0682}
ema_params: {'b': 1.3258, 'w': 2.0765}

Extra exercise 2

See "Extra exercise 2: Welford's online variance with lax.scan + vmap" in the main text for the problem.

# Reference solution
class WelfordState(NamedTuple):
    n: jax.Array
    mean: jax.Array
    m2: jax.Array

def welford_step(state, x):
    n = state.n + 1
    delta = x - state.mean
    mean = state.mean + delta / n
    m2 = state.m2 + delta * (x - mean)
    new_state = WelfordState(n, mean, m2)
    return new_state, m2 / n            # output the current variance at each step

def online_var(xs):
    init = WelfordState(jnp.array(0.0), jnp.array(0.0), jnp.array(0.0))
    final, var_trace = lax.scan(welford_step, init, xs)
    return final.mean, final.m2 / final.n, var_trace

key = jax.random.key(7)
streams = jax.random.normal(key, (4, 1000)) * jnp.array([[1.0], [2.0], [0.5], [3.0]]) + 100.0
means, variances, traces = jax.jit(jax.vmap(online_var))(streams)
print("Welford mean    :", means)
print("jnp.mean        :", streams.mean(axis=1))
print("Welford variance:", variances)
print("jnp.var         :", streams.var(axis=1))
print("Variance trace shape:", traces.shape)
Welford mean    : [ 99.99555  100.016106  99.99033  100.0839  ]
jnp.mean        : [ 99.99555  100.01611   99.990326 100.0839  ]
Welford variance: [1.0395197 4.149059  0.2674239 8.703659 ]
jnp.var         : [1.0395207  4.14906    0.26742393 8.703653  ]
Variance trace shape: (4, 1000)

Extra exercise 3

See "Extra exercise 3: implement RMSProp and plug it into the generic training framework above" in the main text for the problem.

# Reference solution
class RMSPropState(NamedTuple):
    sq_avg: dict

def rmsprop(lr, rho=0.9, eps=1e-8):
    def init(params):
        return RMSPropState(sq_avg=jax.tree.map(jnp.zeros_like, params))
    def update(grads, state, params=None):
        s = jax.tree.map(lambda s, g: rho * s + (1 - rho) * g ** 2, state.sq_avg, grads)
        updates = jax.tree.map(lambda g, s: -lr * g / (jnp.sqrt(s) + eps), grads, s)
        return updates, RMSPropState(sq_avg=s)
    return Optimizer(init, update)

_, _, rms_losses = make_train(rmsprop(0.003), 1000)(params0, X, Y)
print(f"RMSProp final loss: {rms_losses[-1]:.5f}")
print(f"Adam    final loss: {results['Adam'][-1]:.5f}")
RMSProp final loss: 0.00967
Adam    final loss: 0.00003

Extra exercise 4

See "Extra exercise 4: an RNN's hidden state, and taking gradients through lax.scan" in the main text for the problem.

# Reference solution
H, D, T = 16, 1, 20

def init_rnn(key):
    k1, k2, k3 = jax.random.split(key, 3)
    return {'Wh': jax.random.normal(k1, (H, H)) * 0.1,
            'Wx': jax.random.normal(k2, (D, H)) * 0.5,
            'b':  jnp.zeros(H),
            'Wo': jax.random.normal(k3, (H, 1)) * 0.1}

def rnn_forward(params, xs):            # xs: (T, D)
    def cell(h, x):
        h = jnp.tanh(h @ params['Wh'] + x @ params['Wx'] + params['b'])
        return h, h @ params['Wo']      # carry = hidden state, y = this step's output
    h0 = jnp.zeros(H)
    _, ys = lax.scan(cell, h0, xs)
    return ys                            # (T, 1)

def rnn_loss(params, xs_batch, ys_batch):
    preds = jax.vmap(rnn_forward, in_axes=(None, 0))(params, xs_batch)
    return jnp.mean((preds - ys_batch) ** 2)

key = jax.random.key(123)
key, k_data, k_init = jax.random.split(key, 3)
xs_batch = jax.random.uniform(k_data, (64, T, D), minval=-0.5, maxval=0.5)
ys_batch = jnp.cumsum(xs_batch, axis=1)       # target: the running sum

rnn_params = init_rnn(k_init)
rnn_opt = adam(0.01)

@jax.jit
def train_rnn(params, xs, ys):
    """Parameters and data are both passed in as jit arguments, not captured by closures."""
    def rnn_step(carry, _):
        params, opt_state = carry
        loss, grads = jax.value_and_grad(rnn_loss)(params, xs, ys)
        updates, opt_state = rnn_opt.update(grads, opt_state, params)
        return (apply_updates(params, updates), opt_state), loss
    (params, _), losses = lax.scan(rnn_step, (params, rnn_opt.init(params)), None, length=300)
    return params, losses

rnn_params, rnn_losses = train_rnn(rnn_params, xs_batch, ys_batch)
print(f"initial loss {rnn_losses[0]:.5f} → final loss {rnn_losses[-1]:.5f}")
assert rnn_losses[-1] < 0.1 * rnn_losses[0]
print("Gradient structure matches the parameters:", jax.tree.map(jnp.shape, jax.grad(rnn_loss)(rnn_params, xs_batch, ys_batch)))
initial loss 0.78306 → final loss 0.00324
Gradient structure matches the parameters: {'Wh': (16, 16), 'Wo': (16, 1), 'Wx': (1, 16), 'b': (16,)}