the jax path · 0/12
start the path

the jax path · chapter 07 of 12 · part i, the model

Pytrees

A pytree is not a data structure you chose. It is the contract every transformation shares.

the goal Given any nested params structure, predict how jit, grad, and vmap flatten and rebuild it, then register a custom node correctly.

mastery work · this chapter0/7
  1. go →auto
  2. go →auto
  3. go →auto
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
the specimen · at this floor L0 · the program you wrote

Every jaxpr, lowering and dump the JAX chapters read starts as this block.

ROWS = 16
CHANNELS = 32
SEED = 0


def block(x, wq, wk, wv):
    q, k, v = x @ wq, x @ wk, x @ wv
    s = (q @ k.T) / jnp.sqrt(jnp.float32(q.shape[-1]))
    return jax.nn.softmax(s, axis=-1) @ v
read the whole artifact, and the levels above and below it →
bench/specimen/artifacts/source.py · lines 17 to 25 of 40 · python 3.12.0, jax 0.4.38, jaxlib 0.4.38
§ 01

Leaves and structure

Take any nested structure you'd build to hold a model's weights: a dict of dicts, a list of arrays, maybe a tuple mixed in. JAX has a name for exactly that shape, a pytree, any nesting of dicts, lists, tuples, and registered node types, bottoming out in leaves like arrays and scalars. jax.tree.flatten takes one of these and splits it into two things: a flat list of leaves, and a treedef that remembers how to put them back.

This is not a side detail. It is the calling convention every transformation in JAX shares. Call grad on a loss that takes a dict of arrays, and the gradient that comes back is a dict with the exact same keys and shapes, same treedef as the params argument, by construction. jit and vmap do the same thing on the way in, flattening whatever you pass to leaves before tracing touches it. That is why reaching for params = {"dense": {...}, "output": {...}} instead of some bespoke class is idiomatic JAX, not a beginner's habit you'll grow out of.

Every transformation sees your model the same way: leaves on a tree.

One habit from Python trips people here. None looks like it should be a leaf, it's a value, after all, but JAX treats it as an empty subtree: it vanishes from the leaves list entirely. And a list is not a tuple, structurally, even when they hold the same values in the same order. Zip a dict of lists against a dict of tuples with tree.map and you don't get silent coercion. You get a structure mismatch error, because those are two different treedefs pretending to look alike.

what every transformation actually sees: leaves in, leaves out, structure preserved
params {'dense': {'w', 'b'}, 'scale'} flatten leaves w · b · scale treedef the structure, kept aside grad, leafwise new leaves one per input leaf unflatten grad returns the same treedef as params, by construction
§ 02

Tree.map and the rest of the kit

Once every model is leaves and a treedef, updating it stops needing an architecture-specific loop. jax.tree.map(f, tree, *rest) walks every tree in lockstep, structure required to match, and applies f leafwise. Wire the SGD update as params - 0.1 * grads and it is one line, unchanged whether the model has three parameters or three hundred.

The rest of the kit rounds out the same idea. jax.tree.leaves gets you the flat list without the treedef, jax.tree.structure gets you the treedef without the leaves, and jax.tree.unflatten puts a new set of leaves back into an old structure. Together with flatten and map, that is the whole surface most training code needs.

run it: the SGD-style update, unchanged by how deep the model nests
import jax
import jax.numpy as jnp

params = {"dense": {"w": jnp.ones((2, 2)), "b": jnp.zeros(2)}, "scale": jnp.ones(())}
grads = jax.tree.map(jnp.ones_like, params)
new_params = jax.tree.map(lambda p, g: p - 0.1 * g, params, grads)
print(jax.tree.structure(new_params))   # same treedef as params, by construction
§ 03

Your own nodes

A plain dataclass is not automatically a pytree. JAX doesn't know which of its fields are arrays that should flow through transformations and which are configuration that should stay fixed, so left alone, jax.grad or jax.jit would just refuse it, or worse, treat the whole object as one opaque leaf. @jax.tree_util.register_dataclass tells JAX how to read it: which fields are data, which are static, declared right on the field with dataclasses.field(metadata=dict(static=True)).

That decorator covers the common case, a dataclass that's mostly arrays with a few static settings. For a container you want full control over, custom flatten and unflatten logic, not just field-by-field mapping, register_pytree_node is the lower-level tool underneath it. Either way, the lesson is the same: a custom class doesn't opt out of the pytree system by existing. It opts in the moment you register it, and from then on grad, jit, and vmap walk through it exactly as they would a dict.

run it: a registered dataclass, transparent to tree.map
from dataclasses import dataclass

import jax
import jax.numpy as jnp

@jax.tree_util.register_dataclass
@dataclass
class TrainState:
    step: jax.Array
    params: dict

state = TrainState(step=jnp.zeros((), jnp.int32), params={"w": jnp.ones(3)})
bumped = jax.tree.map(lambda x: x + 1, state)
print(bumped.step)   # 1: the dataclass is transparent to every transform
assigned

Readings

go deeper, in order

Lessons

  1. 01Flatten happens firstHand a jitted function a dict of dicts and the program it compiles takes four separate arrays. The nesting is a Python-side convention that stops at the trace boundary, and knowing where it stops explains most of what the transformations do with a model. ·
  2. 02Structure, then leavesEvery transformation checks a tree twice, first the containers and then what is inside them, and it has a different error message for each. Knowing which message belongs to which pass turns most failures into a one-line diagnosis. ·
  3. 03Nodes you registerAn object JAX has never heard of does not raise. It becomes a single leaf, and the complaint arrives later from somewhere else, which is why registration is worth getting exactly right the first time. ·
live

Instrument

EX·17 the flattener
the tree
leaves, in order
0b1w2scale
the idiomatic params tree
treedef{'dense': {'b': *, 'w': *}, 'scale': *}grad hands this exact structure back, which is why params can be any shape you like as long as the shape is stable
tree.map against

different treedefs: tree.map refuses, and the error names the first place they disagree

change the tree and watch the leaves and the treedef move · the rules run exactly here, including None as an empty subtree