Skip to main content

Welcome to a Grasp lesson

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

Lesson 30

Building Custom Equinox Modules

Recommended resources

Welcome back. In our last lesson, we explored the core philosophy of Equinox: treating neural networks as callable pytrees. This design choice elegantly bridges the object-oriented model definitions you're familiar with from PyTorch and the purely functional paradigm of JAX. You saw that because the entire model is a pytree, JAX's transformations like grad and jit can operate on it directly, with filtering mechanisms to distinguish between trainable arrays and static data.

Today, we transition from that conceptual foundation to practical implementation. The goal of this lesson is to equip you to define your own custom eqx.Module. By the end of this hour, you will be able to construct a module from scratch, defining its parameters, composing it from other modules (submodules), and incorporating static, non-trainable fields. This is the fundamental skill for building any architecture in Equinox, from a simple MLP to a Transformer.

1. The Anatomy of an eqx.Module

At its heart, an eqx.Module is a Python class that inherits from equinox.Module. This base class automatically handles the boilerplate for registering your custom class as a JAX pytree node. This means JAX will know how to flatten your model into its constituent arrays and static data, and how to unflatten them back into a model instance.

The structure is reminiscent of Python's dataclasses. You define the fields of your module as class attributes with type hints. These fields become the children of the module's pytree.

The three key components you'll define are:

  1. Fields (Class Attributes): Declared with type hints at the class level. These define the structure of your model's pytree.
  2. __init__ method: The constructor where you create and assign the initial values for the fields. This is where you'll initialize weights, biases, and any submodules.
  3. __call__ method: The forward pass. This defines what the model does when it's called like a function.

Let's start by looking at a concrete example from the Equinox documentation, which we touched upon last time.

All of Equinox - Equinox

This document provides a great, concise overview. We will focus on the first example that builds a simple neural network.

Please read the section "Models as PyTrees" and study the NeuralNetwork code block that follows. Pay close attention to how the class is structured: Notice the class attributes layers: tuple and extra_bias: jax.Array. These annotations define the fields of the module. Examine the __init__ method. See how self.layers is initialized as a tuple of eqx.nn.Linear modules and self.extra_bias is initialized as a jax.numpy array. Look at the __call__ method. It defines the forward pass by iterating through the layers.

This NeuralNetwork class perfectly illustrates the concepts of parameters and submodules.

  • Parameters: self.extra_bias is a parameter. It's a raw jax.Array that is part of the model's trainable state.
  • Submodules: self.layers is a tuple containing eqx.nn.Linear objects. Each eqx.nn.Linear is itself an eqx.Module with its own parameters (weight and bias). This demonstrates the compositional nature of Equinox: models are nested pytrees of other models and parameters.

When JAX flattens an instance of this NeuralNetwork, it recursively traverses this structure, collecting all the JAX arrays from extra_bias and from the weights and biases inside each of the Linear layers.


2. Defining a Module from Scratch

To make this more concrete, let's watch a practical demonstration. The following video walks through the process of creating a simple MLP, defining its layers in __init__ and its forward pass in __call__. This is a very common pattern and seeing it coded live is instructive.

Neural Networks in Equinox (JAX DL framework) with Optax

This video from the "Machine Learning & Simulation" channel provides an excellent walkthrough of building a simple MLP.

Please watch the following segments: Class Definition and __init__: From this section, observe how the SimpleMLP class is defined. The presenter sets up the __init__ method to take layer_sizes and a key. Inside, they iterate to create a list of eqx.nn.Linear layers, carefully managing the PRNG key by splitting it at each step. This is the idiomatic way to initialize a sequence of layers. The __call__ Method: Next, watch the implementation of the forward pass. The code iterates through the layers, applying each one followed by a sigmoid activation. Note how the final layer is handled separately to omit the last activation—a common requirement in many architectures. Instantiation: Finally, see how the model is instantiated from this brief clip. It's as simple as calling the class with the desired configuration. The video notes that Equinox handles the parameter initialization for you within the eqx.nn.Linear modules.

This process directly mirrors the structure of PyTorch nn.Modules, yet the underlying mechanism is entirely different. In PyTorch, nn.Linear registers its parameters with the parent module behind the scenes. In Equinox, the eqx.nn.Linear object is simply a value assigned to an attribute, and the pytree mechanism makes it visible to JAX.


3. Static vs. Dynamic Fields

So far, all the fields in our examples (layers, extra_bias) have contained JAX arrays. But what if we want to include attributes that are not arrays, such as an activation function, a string, or a boolean flag? These are what we call static fields.

Equinox handles this transparently. Any field that is not a JAX array (or a container of JAX arrays) is considered static. When you use a filtered transformation like eqx.filter_grad or eqx.filter_jit, Equinox automatically partitions the model pytree.

  • The dynamic part (all JAX arrays) is passed to the underlying JAX transformation (jax.grad or jax.jit). These are the parameters that can be differentiated or are involved in compiled computation.
  • The static part (everything else) is treated as compile-time constants. The JIT compiler can specialize the compiled code based on the values of these static fields.

Let's modify our NeuralNetwork to include a static field. Instead of hard-coding jax.nn.relu in the __call__ method, we can store it as an attribute.

import jax
import equinox as eqx
from typing import Callable

class FlexibleNetwork(eqx.Module):
    layers: list
    activation: Callable

    def __init__(self, key):
        key1, key2 = jax.random.split(key)
        self.layers = [
            eqx.nn.Linear(2, 8, key=key1),
            eqx.nn.Linear(8, 2, key=key2)
        ]
        self.activation = jax.nn.relu

    def __call__(self, x):
        x = self.layers[0](x)
        x = self.activation(x)
        x = self.layers[1](x)
        return x

In this FlexibleNetwork:

  • self.layers is a list of submodules containing dynamic JAX arrays (weights and biases).
  • self.activation is a static field. Its value is a Python function (jax.nn.relu), which is not a JAX array.

When we use eqx.filter_grad on a loss function involving this model, Equinox will identify the weights and biases in self.layers as the dynamic parts to differentiate, and it will treat self.activation as a static part of the model's structure.

The "All of Equinox" document illustrates this by placing jax.nn.relu directly into the list of layers.

All of Equinox - Equinox

Let's revisit the documentation to see how it introduces static components.

Read the section "Filtering" and look at the NeuralNetwork2 code example. Here, jax.nn.relu is included directly in the self.layers tuple. This is another valid way to structure the model, treating the activation function as a layer in a sequence. This makes the __call__ method a simple loop, which can be elegant for sequential models.

Whether you store a static field as a separate attribute (like self.activation) or embed it within a collection (like self.layers) is a design choice. The former is often clearer for distinct configuration options, while the latter can be convenient for building sequential models.


Conclusion

In this lesson, you've learned the practical mechanics of building your own neural network modules in Equinox. We moved from the abstract idea of "models as pytrees" to the concrete implementation of eqx.Module subclasses.

Key Takeaways:

  • eqx.Module Structure: A custom module inherits from eqx.Module and is typically defined by its class-level field declarations, an __init__ method for initialization, and a __call__ method for the forward pass.
  • Parameters and Submodules: These are fields that contain JAX arrays, either directly (jax.Array) or nested within other eqx.Module instances. They constitute the dynamic, trainable part of your model.
  • Static Fields: These are fields containing any non-array Python object, such as functions, booleans, or strings. They are treated as fixed configuration by JAX's transformations.
  • Composition: eqx.Modules are highly compositional. Complex models are built by nesting simpler modules, forming a clean, hierarchical pytree structure.

You now have the tools to define the building blocks of any neural network. In the next lesson, we will focus on how to work with the model you've created. Specifically, you'll learn how to use Equinox's filter utilities like eqx.partition and eqx.combine to precisely separate the trainable parameters from the static structure for tasks like optimization and state management.