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

Pytree Utilities: flatten, unflatten, and Debugging

~10 min · pytrees, jax, tutorial

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

Sometimes you need to go beyond tree.map and work with the flat list of leaves directly — for example, to concatenate all parameters into a single vector, or to debug which parameter has NaN values.

Flatten and Unflatten

import jax
import jax.numpy as jnp

params = {
    'layer1': {'w': jnp.array([[1.0, 2.0], [3.0, 4.0]]), 'b': jnp.array([0.1, 0.2])},
    'layer2': {'w': jnp.array([[5.0], [6.0]]), 'b': jnp.array([0.3])},
}

# Flatten: get leaves and structure separately
leaves, treedef = jax.tree.flatten(params)
print(f"Number of leaves: {len(leaves)}")  # 4
print(f"Tree structure: {treedef}")

# You can reconstruct the original tree
params_rebuilt = treedef.unflatten(leaves)
# params_rebuilt == params (same structure and values)

# Useful: concatenate all params into one vector
all_params = jnp.concatenate([x.ravel() for x in leaves])
print(f"Total params as one vector: {all_params.shape}")  # (9,)

Debugging with flatten_with_path

When something goes wrong (NaN gradients, unexpected shapes), you need to know which parameter is the problem. jax.tree.flatten_with_path gives you the path to each leaf:

# Get paths alongside leaves
path_leaves, treedef = jax.tree.flatten_with_path(params)

for path, leaf in path_leaves:
    path_str = jax.tree_util.keystr(path)
    print(f"{path_str}: shape={leaf.shape}, dtype={leaf.dtype}")

# Output:
# ['layer1']['b']: shape=(2,), dtype=float32
# ['layer1']['w']: shape=(2, 2), dtype=float32
# ['layer2']['b']: shape=(1,), dtype=float32
# ['layer2']['w']: shape=(2, 1), dtype=float32
# Find NaN parameters
def check_for_nans(params):
    path_leaves, _ = jax.tree.flatten_with_path(params)
    for path, leaf in path_leaves:
        if jnp.any(jnp.isnan(leaf)):
            print(f"NaN found at {jax.tree_util.keystr(path)}!")

# Check parameter statistics
def param_summary(params):
    path_leaves, _ = jax.tree.flatten_with_path(params)
    for path, leaf in path_leaves:
        name = jax.tree_util.keystr(path)
        print(f"{name}: mean={leaf.mean():.4f}, std={leaf.std():.4f}, "
              f"shape={leaf.shape}")

param_summary(params)
# ['layer1']['b']: mean=0.1500, std=0.0500, shape=(2,)
# ['layer1']['w']: mean=2.5000, std=1.1180, shape=(2, 2)
# ...

💡 Why This Matters

keystr is invaluable for debugging. When your model has 50 layers and gradient norms explode, you need to quickly identify which layer is the culprit. flatten_with_path + keystr gives you human-readable paths like ['encoder']['attention']['query']['weights'] instead of just "leaf 37 of 200."

Here's a comparison with how PyTorch handles the same task:

# PyTorch equivalent: model.state_dict()
# state_dict = model.state_dict()
# for name, param in state_dict.items():
#     print(f"{name}: {param.shape}")
# Output: "layer1.weight: torch.Size([4, 3])"

# JAX equivalent using pytrees:
path_leaves, _ = jax.tree.flatten_with_path(params)
for path, leaf in path_leaves:
    print(f"{jax.tree_util.keystr(path)}: {leaf.shape}")
# Output: "['layer1']['w']: (3, 4)"

# Both give named access to all parameters — different syntax, same idea

Code

import jax
import jax.numpy as jnp

params = {
    'layer1': {'w': jnp.array([[1.0, 2.0], [3.0, 4.0]]), 'b': jnp.array([0.1, 0.2])},
    'layer2': {'w': jnp.array([[5.0], [6.0]]), 'b': jnp.array([0.3])},
}

# Flatten: get leaves and structure separately
leaves, treedef = jax.tree.flatten(params)
print(f"Number of leaves: {len(leaves)}")  # 4
print(f"Tree structure: {treedef}")

# You can reconstruct the original tree
params_rebuilt = treedef.unflatten(leaves)
# params_rebuilt == params (same structure and values)

# Useful: concatenate all params into one vector
all_params = jnp.concatenate([x.ravel() for x in leaves])
print(f"Total params as one vector: {all_params.shape}")  # (9,)
# Get paths alongside leaves
path_leaves, treedef = jax.tree.flatten_with_path(params)

for path, leaf in path_leaves:
    path_str = jax.tree_util.keystr(path)
    print(f"{path_str}: shape={leaf.shape}, dtype={leaf.dtype}")

# Output:
# ['layer1']['b']: shape=(2,), dtype=float32
# ['layer1']['w']: shape=(2, 2), dtype=float32
# ['layer2']['b']: shape=(1,), dtype=float32
# ['layer2']['w']: shape=(2, 1), dtype=float32
# Find NaN parameters
def check_for_nans(params):
    path_leaves, _ = jax.tree.flatten_with_path(params)
    for path, leaf in path_leaves:
        if jnp.any(jnp.isnan(leaf)):
            print(f"NaN found at {jax.tree_util.keystr(path)}!")

# Check parameter statistics
def param_summary(params):
    path_leaves, _ = jax.tree.flatten_with_path(params)
    for path, leaf in path_leaves:
        name = jax.tree_util.keystr(path)
        print(f"{name}: mean={leaf.mean():.4f}, std={leaf.std():.4f}, "
              f"shape={leaf.shape}")

param_summary(params)
# ['layer1']['b']: mean=0.1500, std=0.0500, shape=(2,)
# ['layer1']['w']: mean=2.5000, std=1.1180, shape=(2, 2)
# ...
# PyTorch equivalent: model.state_dict()
# state_dict = model.state_dict()
# for name, param in state_dict.items():
#     print(f"{name}: {param.shape}")
# Output: "layer1.weight: torch.Size([4, 3])"

# JAX equivalent using pytrees:
path_leaves, _ = jax.tree.flatten_with_path(params)
for path, leaf in path_leaves:
    print(f"{jax.tree_util.keystr(path)}: {leaf.shape}")
# Output: "['layer1']['w']: (3, 4)"

# Both give named access to all parameters — different syntax, same idea

External links

Exercise

Take a complex nested params tree. Flatten it, count leaves, sum total parameters via jax.tree.reduce. Then unflatten and verify equality. This is how 'count my model params' usually shows up — and how Optax internally tracks state.

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.