C.W.K.
Stream
Lesson 05 of 05 · published

Pytrees in Practice: Neural Networks as Pytrees

~12 min · pytrees, jax, tutorial

Level 0Curious
0 XP0/73 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete

Let's put everything together and build a simple neural network represented entirely as a pytree of parameters. This is the fundamental pattern that underlies all JAX neural network libraries.

import jax
import jax.numpy as jnp

def init_mlp(key, layer_sizes):
    """Initialize an MLP as a pytree (list of dicts)."""
    params = []
    for i in range(len(layer_sizes) - 1):
        key, k1, k2 = jax.random.split(key, 3)
        fan_in, fan_out = layer_sizes[i], layer_sizes[i + 1]
        # Glorot/Xavier initialization
        std = jnp.sqrt(2.0 / (fan_in + fan_out))
        params.append({
            'weights': jax.random.normal(k1, (fan_in, fan_out)) * std,
            'bias': jnp.zeros(fan_out),
        })
    return params

def mlp_forward(params, x):
    """Forward pass through the MLP pytree."""
    for i, layer in enumerate(params[:-1]):
        x = x @ layer['weights'] + layer['bias']
        x = jax.nn.relu(x)
    # Last layer: no activation
    x = x @ params[-1]['weights'] + params[-1]['bias']
    return x

# Initialize
key = jax.random.key(42)
params = init_mlp(key, [784, 256, 128, 10])

# Check structure
for i, layer in enumerate(params):
    print(f"Layer {i}: weights {layer['weights'].shape}, "
          f"bias {layer['bias'].shape}")
# Layer 0: weights (784, 256), bias (256,)
# Layer 1: weights (256, 128), bias (128,)
# Layer 2: weights (128, 10), bias (10,)

Training This Pytree Model

def loss_fn(params, x, y):
    """Cross-entropy loss for classification."""
    logits = mlp_forward(params, x)
    log_probs = jax.nn.log_softmax(logits)
    return -jnp.mean(jnp.sum(log_probs * y, axis=-1))

@jax.jit
def train_step(params, x, y, lr=0.001):
    """Single training step — everything is pytrees."""
    loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
    # SGD: subtract scaled gradients from every parameter
    new_params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
    return new_params, loss

# Simulate training
x_batch = jax.random.normal(jax.random.key(0), (32, 784))
y_batch = jax.nn.one_hot(jnp.arange(32) % 10, 10)

for step in range(100):
    params, loss = train_step(params, x_batch, y_batch)
    if step % 20 == 0:
        print(f"Step {step}: loss = {loss:.4f}")

Pytree-Aware Operations

Nearly every JAX operation is pytree-aware:

# grad returns a pytree matching the input
grads = jax.grad(loss_fn)(params, x_batch, y_batch)
# grads[0]['weights'].shape == params[0]['weights'].shape ✓

# jit works on functions that take/return pytrees
fast_forward = jax.jit(mlp_forward)
logits = fast_forward(params, x_batch)

# vmap over the batch dimension while keeping params fixed
single_predict = lambda x: mlp_forward(params, x)
batch_logits = jax.vmap(single_predict)(x_batch)

# Compute parameter norms
param_norms = jax.tree.map(jnp.linalg.norm, params)
# param_norms has same structure: list of {'weights': scalar, 'bias': scalar}

# Total L2 regularization
l2_reg = sum(jnp.sum(x ** 2) for x in jax.tree.leaves(params))
print(f"L2 norm: {jnp.sqrt(l2_reg):.2f}")

💡 Why This Matters

This pattern — parameters as a plain pytree, separate functions for forward pass and loss — is the foundation of all JAX neural network code. Libraries like Flax and Equinox add convenience (module classes, automatic parameter management), but they all build on this same pytree machinery. If you understand pytrees, you understand JAX's approach to deep learning.

Compare this with PyTorch's approach:

# PyTorch: parameters live inside the Module object
# class MLP(nn.Module):
#     def __init__(self):
#         self.layer1 = nn.Linear(784, 256)
#     def forward(self, x):
#         return self.layer1(x)
# model = MLP()
# optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# JAX: parameters are a plain data structure, functions are separate
# params = init_mlp(key, [784, 256, 10])
# grads = jax.grad(loss_fn)(params, x, y)
# params = jax.tree.map(lambda p, g: p - lr * g, params, grads)

# Both work — JAX separates data from logic, PyTorch bundles them together

Code

import jax
import jax.numpy as jnp

def init_mlp(key, layer_sizes):
    """Initialize an MLP as a pytree (list of dicts)."""
    params = []
    for i in range(len(layer_sizes) - 1):
        key, k1, k2 = jax.random.split(key, 3)
        fan_in, fan_out = layer_sizes[i], layer_sizes[i + 1]
        # Glorot/Xavier initialization
        std = jnp.sqrt(2.0 / (fan_in + fan_out))
        params.append({
            'weights': jax.random.normal(k1, (fan_in, fan_out)) * std,
            'bias': jnp.zeros(fan_out),
        })
    return params

def mlp_forward(params, x):
    """Forward pass through the MLP pytree."""
    for i, layer in enumerate(params[:-1]):
        x = x @ layer['weights'] + layer['bias']
        x = jax.nn.relu(x)
    # Last layer: no activation
    x = x @ params[-1]['weights'] + params[-1]['bias']
    return x

# Initialize
key = jax.random.key(42)
params = init_mlp(key, [784, 256, 128, 10])

# Check structure
for i, layer in enumerate(params):
    print(f"Layer {i}: weights {layer['weights'].shape}, "
          f"bias {layer['bias'].shape}")
# Layer 0: weights (784, 256), bias (256,)
# Layer 1: weights (256, 128), bias (128,)
# Layer 2: weights (128, 10), bias (10,)
def loss_fn(params, x, y):
    """Cross-entropy loss for classification."""
    logits = mlp_forward(params, x)
    log_probs = jax.nn.log_softmax(logits)
    return -jnp.mean(jnp.sum(log_probs * y, axis=-1))

@jax.jit
def train_step(params, x, y, lr=0.001):
    """Single training step — everything is pytrees."""
    loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
    # SGD: subtract scaled gradients from every parameter
    new_params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
    return new_params, loss

# Simulate training
x_batch = jax.random.normal(jax.random.key(0), (32, 784))
y_batch = jax.nn.one_hot(jnp.arange(32) % 10, 10)

for step in range(100):
    params, loss = train_step(params, x_batch, y_batch)
    if step % 20 == 0:
        print(f"Step {step}: loss = {loss:.4f}")
# grad returns a pytree matching the input
grads = jax.grad(loss_fn)(params, x_batch, y_batch)
# grads[0]['weights'].shape == params[0]['weights'].shape ✓

# jit works on functions that take/return pytrees
fast_forward = jax.jit(mlp_forward)
logits = fast_forward(params, x_batch)

# vmap over the batch dimension while keeping params fixed
single_predict = lambda x: mlp_forward(params, x)
batch_logits = jax.vmap(single_predict)(x_batch)

# Compute parameter norms
param_norms = jax.tree.map(jnp.linalg.norm, params)
# param_norms has same structure: list of {'weights': scalar, 'bias': scalar}

# Total L2 regularization
l2_reg = sum(jnp.sum(x ** 2) for x in jax.tree.leaves(params))
print(f"L2 norm: {jnp.sqrt(l2_reg):.2f}")
# PyTorch: parameters live inside the Module object
# class MLP(nn.Module):
#     def __init__(self):
#         self.layer1 = nn.Linear(784, 256)
#     def forward(self, x):
#         return self.layer1(x)
# model = MLP()
# optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# JAX: parameters are a plain data structure, functions are separate
# params = init_mlp(key, [784, 256, 10])
# grads = jax.grad(loss_fn)(params, x, y)
# params = jax.tree.map(lambda p, g: p - lr * g, params, grads)

# Both work — JAX separates data from logic, PyTorch bundles them together

External links

Exercise

Define a 2-layer MLP whose params are a single nested dict (a pytree). Initialize, forward-pass, compute MSE, take grad. Compare the final params dict's structure to the initial — they should match leaf-for-leaf. The whole quest's mental model lands here.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.