Hello! Let's continue our journey into JAX and Equinox.
In our last lesson, we focused on making our training code efficient by using eqx.filter_jit and eqx.filter_grad to create a fully compiled training step. However, the models we've worked with so far have been entirely deterministic. In practice, many neural network architectures use components like dropout or stochastic depth that behave differently during training and inference.
This lesson addresses that gap. We will explore how to manage these conditional behaviors in the pure functional paradigm of JAX. You'll learn to implement an inference-mode flag to control operations like dropout and, crucially, how to manage the explicit PRNG keys that JAX's stochastic functions require. By the end, you will be able to build models that have distinct, reproducible behaviors for training and evaluation.
The Challenge: State in a Stateless World
In PyTorch, toggling between training and evaluation modes is typically handled by calling model.train() or model.eval(). These methods recursively traverse the module hierarchy, flipping an internal boolean flag, self.training, on each submodule. This is a classic object-oriented approach involving state mutation.
JAX, by design, discourages this pattern. Its transformations, like jit, are designed for pure functions, whose outputs depend solely on their inputs, not on some hidden, mutable state. A function call shouldn't have side effects like changing a flag inside an object.
So, how do we tell a module like dropout whether to be active? The functional solution is to make the mode an explicit input to the function. Instead of mutating an internal state, we pass a flag—conventionally a boolean like deterministic—to the model's __call__ method.
model(x, deterministic=False)for training (dropout is active).model(x, deterministic=True)for inference (dropout is inactive).
This approach makes the model's behavior explicit and transparent, perfectly aligning with the principles of functional programming.
Implementing Conditional Dropout
The second, and equally important, part of the JAX paradigm is the explicit handling of randomness. In PyTorch, torch.nn.Dropout uses a global, implicit random number generator. In JAX, there is no global RNG. Any function that requires randomness must be given a jax.random.PRNGKey as an explicit argument.
Let's see how these two concepts—the deterministic flag and explicit keys—come together in an Equinox implementation of a Dropout layer. The following resource provides a concise, canonical example.
This document provides a set of notes and code snippets for translating PyTorch concepts to JAX/Equinox.
Please read the section on dropout. Pay close attention to the __call__ method's signature and the conditional logic inside.
Let's break down the implementation from the resource:
class Dropout(eqx.Module):
rate: float
def __call__(self, x, *, key, deterministic):
if deterministic:
return x
mask = jax.random.bernoulli(key=key, p=1 - self.rate, shape=x.shape)
return x * mask / (1 - self.rate)
__call__(self, x, *, key, deterministic): The signature is the most important piece. It takes the dataxas a positional argument. Crucially, it also takeskeyanddeterministicas keyword-only arguments. This is a robust pattern; it forces the caller to be explicit (e.g.,model(x, key=..., deterministic=...)) and prevents bugs from passing arguments in the wrong order.if deterministic:: This is the explicit mode switch. IfTrue, the function is a simple identity, and thekeyis unused.jax.random.bernoulli(key=key, ...): IfdeterministicisFalse, we use the providedkeyto generate the dropout mask. This makes the stochasticity of the function a direct and reproducible result of its inputs.- Inverted Dropout: The scaling by
1 / (1 - self.rate)is the standard "inverted dropout" technique, which ensures the expected value of the activations remains the same between training and inference, so no changes are needed at inference time.
Threading the Keys: Managing Randomness at Scale
A single Dropout layer is straightforward. But what happens when your model has multiple dropout layers, or other stochastic components?
class MyModel(eqx.Module):
layer1: eqx.nn.Linear
dropout1: Dropout
layer2: eqx.nn.Linear
dropout2: Dropout
def __call__(self, x, *, key, deterministic: bool):
# ... what to do here?
You cannot pass the same key to both dropout1 and dropout2. If you did, they would produce identical (or at least highly correlated) random masks, defeating the purpose of independent regularization.
The solution is to split the key. A PRNGKey is just a pair of integers. The jax.random.split function uses a key to produce two or more new, independent keys.
Here is the correct way to implement the __call__ method for MyModel:
def __call__(self, x, *, key, deterministic: bool):
# During inference, no key is needed.
if deterministic:
key_for_d1, key_for_d2 = None, None
else:
# Create two new sub-keys from the main key
key_for_d1, key_for_d2 = jax.random.split(key)
x = self.layer1(x)
x = self.dropout1(x, key=key_for_d1, deterministic=deterministic)
x = self.layer2(x)
x = self.dropout2(x, key=key_for_d2, deterministic=deterministic)
return x
This pattern of passing a key to a model, splitting it, and passing the sub-keys to sub-modules is fundamental to writing stochastic models in JAX. It's often called "threading the keys" through the model. For models with layers in a loop (e.g., a Transformer), you would typically use jax.random.fold_in to derive a unique key for each iteration, as hinted at in the resource you just read.
Integrating into the Training Step
Finally, let's integrate this into the make_step function we developed in the previous lesson. The need to handle keys and flags propagates all the way up the call stack.
-
The Loss Function: The function that computes the loss now needs a
keyto pass to the model, and it must operate in non-deterministic mode.@eqx.filter_jit def compute_loss(model, x, y, key): # The `deterministic` flag is hard-coded to False for training. pred_y = model(x, key=key, deterministic=False) # Assuming a standard cross-entropy loss return jnp.mean(-jnp.sum(y * jax.nn.log_softmax(pred_y), axis=-1))Note that
eqx.filter_value_and_gradwill now need to be applied to a version of this function that takes thekeyargument. -
The Step Function: The
make_stepfunction, which we JIT-compile, now needs to accept a key for the current step.# Re-define value_and_grad_loss to close over the key value_and_grad_loss = eqx.filter_value_and_grad(compute_loss, arg="model") @eqx.filter_jit def make_step(model, opt_state, x, y, key): loss_val, grads = value_and_grad_loss(model, x, y, key) updates, opt_state = optimizer.update(grads, opt_state, model) model = eqx.apply_updates(model, updates) return model, opt_state, loss_val -
The Training Loop: The outermost training loop is now responsible for managing the master key and generating a new, unique key for each and every training step.
# In your main script key = jax.random.PRNGKey(seed=42) model = ... opt_state = ... for batch_x, batch_y in dataloader: # CRUCIAL: Split the key for each step! key, step_key = jax.random.split(key) # Pass the unique key for this step into the compiled function model, opt_state, loss = make_step(model, opt_state, batch_x, batch_y, step_key) print(f"Loss: {loss}")
This final piece is critical. If you reuse the same step_key, your model will see the exact same dropout mask for every batch, which severely compromises training. By splitting the key inside the loop, you ensure that each step gets fresh randomness, just as intended.
For evaluation, you would write a separate evaluate_step function where deterministic=True is passed to the model and no key handling is required.
Conclusion
In this lesson, you've learned the pure functional approach to managing conditional logic and randomness in JAX models. This stands in sharp contrast to the stateful, object-oriented patterns common in frameworks like PyTorch.
Key Takeaways:
- Instead of
model.train()/eval(), JAX models use an explicitdeterministic: boolflag passed to the__call__method to switch between training and inference behavior. - All stochastic operations, like dropout, require an explicit
jax.random.PRNGKeyto be passed as an argument. - Models with multiple stochastic components must "thread the keys" by splitting a main key and passing the resulting sub-keys to sub-modules.
- The main training loop is responsible for generating a new, unique PRNG key for every training step to ensure independent randomness across batches.
We've now seen how to handle parameters (via filter_grad) and conditional, stochastic operations. But what about model components that need to evolve over time but aren't trainable parameters, like the running mean and variance in a BatchNorm layer? In our next lesson, we will tackle this exact problem, learning how to manage and update evolving state within the functional make_step pattern.