Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 21

Surgical PyTree Updates with `equinox.tree_at`

Recommended resources

Welcome to our next lesson. In our previous discussion, we explored the crucial distinction between static and dynamic fields within JAX pytrees, and how Equinox elegantly manages this split. We established that dynamic fields (arrays) are the "live" data that flow through computations, while static fields (like functions or integers) define the computation's structure at compile time.

This functional, compile-time-oriented design hinges on one core principle: immutability. You cannot change a JAX array or an Equinox model in place. This lesson tackles the natural next question: how do we modify things? How do we apply optimizer updates, freeze layers, or change a single hyperparameter inside a model? The answer lies in creating a new model with the desired changes. Today, you will learn to perform these "surgical" updates functionally, using equinox.tree_at and a suite of powerful filtering utilities.

1. The Functional Approach to Updates: equinox.tree_at

In a stateful framework like PyTorch, you might update a parameter directly: model.layer.weight = new_weight. In JAX, this is an anti-pattern. Instead, we create a new object. The primary tool for this in Equinox is equinox.tree_at. It allows you to produce a new pytree that is an exact copy of an old one, with the exception of specific leaves that you've replaced.

The function signature is equinox.tree_at(where, pytree, replace).

  • pytree: The original object you want to modify, for example, an eqx.Module.
  • where: A function that navigates the pytree and returns the leaf or leaves you wish to change. This is a remarkably expressive way to specify a location within a nested structure.
  • replace: The new value(s) for the leaves identified by where. This must be a pytree with the same structure as the output of where.

Let's consider a simple example.

import jax.numpy as jnp
import equinox as eqx
import jax.random as jrandom




# A simple module
class MyModel(eqx.Module):
    layer: eqx.nn.Linear
    some_value: jnp.ndarray

    def __init__(self, key):
        self.layer = eqx.nn.Linear(2, 2, key=key)
        self.some_value = jnp.array(1.0)

key = jrandom.PRNGKey(0)
model = MyModel(key)

print("Original model's 'some_value':", model.some_value)




# Define a 'where' function to locate the `some_value` attribute
where_fn = lambda m: m.some_value




# Create a new model with `some_value` updated to 99.0
new_model = eqx.tree_at(where_fn, model, jnp.array(99.0))

print("New model's 'some_value':", new_model.some_value)
print("Original model is unchanged:", model.some_value)

As you can see, model remains untouched, and new_model is a distinct object with the updated value. The where function, lambda m: m.some_value, acts as a "path" into the pytree structure. This functional approach to specifying paths is a common and powerful idiom in JAX libraries. You can also target multiple leaves by having the where function return a tuple, for instance: lambda m: (m.layer.weight, m.layer.bias).


2. The Filter-Partition-Combine Pattern

The most common use case for surgical updates is to apply an operation to only a subset of a model's parameters. A classic example is freezing the initial layers of a pre-trained model during fine-tuning. In Equinox, this is achieved with a three-step pattern:

  1. Filter: Create a "filter specification," which is a pytree of booleans with the same structure as your model. A True value marks a leaf as "active" for the operation, while False marks it as "inactive."
  2. Partition: Use equinox.partition(model, filter_spec) to split your model into two new pytrees. One contains all the leaves marked True, and the other contains all the leaves marked False.
  3. Combine: After performing an operation on the desired partition (e.g., computing gradients or applying updates), use equinox.combine(partition1, partition2) to merge them back into a single, updated model.

This pattern elegantly sidesteps the need for mutable state like requires_grad. It's a purely functional way to achieve the same goal. The key is constructing the filter_spec, and this is where equinox.tree_at becomes indispensable. You typically start with a pytree of all Falses and then use tree_at to surgically flip the desired leaves to True.


3. Example: Freezing Layers for Training

Let's walk through a complete example of this pattern in action: training an MLP while keeping all but the final layer "frozen." The following resource implements this exact scenario.

Freezing parameters - Equinox

This example demonstrates the entire workflow of freezing parameters in a training loop.

Read through the code in the main function, paying close attention to the numbered steps. Step 2: Creating the Filter Spec. The code first creates a filter_spec with the same structure as the model, but with all leaves set to False using jtu.tree_map. Then, study how eqx.tree_at is used to selectively set the weight and bias of the final layer (tree.layers[-1]) to True. This is the surgical update that specifies which parameters are trainable. Step 3: The Training Step. Inside the make_step function, observe how eqx.partition is called. The model is split into diff_model (the parts we want to differentiate, i.e., the last layer) and static_model (the frozen parts). Notice that the loss function takes both, combines them internally, and crucially, eqx.filter_grad is applied only to diff_model. Finally, eqx.apply_updates correctly applies the calculated gradients to the full model structure. Verification. Look at the final print outputs. Compare the parameters of the first and last layers before and after training. You will see that the first layer's parameters are identical, proving they were successfully frozen, while the last layer's parameters have been updated.

This example encapsulates the entire philosophy. Instead of mutating flags on parameters, you create an explicit filter specification and functionally partition your model. This makes the data flow of your training step exceptionally clear: gradients are only computed for the diff_model, and updates are only applied to its corresponding leaves in the original model structure.


4. Other Filtering Utilities

The partition/combine pattern is built on a more fundamental function, equinox.filter.

  • equinox.filter(pytree, filter_spec): This function takes a pytree and a filter spec. It returns a new pytree where leaves corresponding to True in the filter spec are kept, and leaves corresponding to False are replaced with None (or another specified value).

In fact, eqx.partition(pytree, spec) is just a convenient shorthand for:
(eqx.filter(pytree, spec), eqx.filter(pytree, spec, inverse=True))

While you will often use partition directly for its clarity, knowing about filter is useful for cases where you might only need one part of the split. These utilities, combined with tree_at for creating the filters, form a comprehensive toolkit for manipulating pytrees in a functional, explicit, and predictable manner.


Conclusion

Today you've bridged the gap between understanding the immutable, functional nature of JAX and performing practical model updates. You've learned the primary patterns for modifying pytrees without violating immutability.

Key Takeaways:

  • Updates in JAX/Equinox are functional: you create a new object with the desired changes instead of modifying an existing one.
  • equinox.tree_at is the main tool for performing these surgical updates, using a where function to specify the update location.
  • To apply operations to a subset of a model, you use the Filter-Partition-Combine pattern.
  • This involves creating a boolean filter_spec pytree (often with tree_at), using eqx.partition to split the model, operating on one part, and then using eqx.combine to merge the results.
  • This declarative approach replaces the stateful param.requires_grad = False idiom from frameworks like PyTorch, making the flow of gradients and updates more explicit.

In our next lesson, we will address the inevitable consequence of manipulating complex data structures: errors. Now that you know how to transform and update pytrees, you'll learn how to diagnose common problems that arise, such as the dreaded "treedef mismatch" error, ensuring you can debug your JAX models effectively.