Recurrent Networks: RNN, LSTM & GRU
Teach a network to remember. Recurrent networks process a sequence one step at a time, carrying a hidden state forward so that earlier tokens can influence later predictions. This guide builds vanilla RNN, LSTM, GRU, and bidirectional classifiers in Flax NNX, derives the LSTM gate equations, and trains them on a parity task that cannot be solved without integrating information across every time step.
Comfortable defining modules and running a training loop? You should have read your first model, understanding state, and simple training. Recurrent layers are stateful, so the state page is especially relevant.
- How
nnx.RNNturns any recurrent cell into a layer that scans over time - The LSTM gate equations β forget, input, output β and the two-part carry
(h, c) - Why gating fixes vanishing gradients where a vanilla RNN fails
- How to swap in
nnx.GRUCell,nnx.SimpleCell, andnnx.Bidirectional - What
nnx.RNNdoes under the hood, written out as an explicitnnx.scanwith carry threading
See the full implementation: examples/sequence/rnn_cells.py
Why Recurrence?β
An MLP or CNN sees a fixed-size input all at once. Sequences β text, audio, sensor streams, time series β are different: they have variable length and their order carries meaning. A recurrent network processes the sequence step by step, maintaining a hidden state that summarizes everything seen so far:
The same cell is applied at every step, sharing parameters across time. That single recurrence is the whole idea β but making it learn long-range dependencies is where LSTMs and GRUs come in.
The task: parityβ
Our benchmark is deliberately unforgiving. Given a sequence of integer tokens, predict the parity of their sum:
Parity is a perfect recurrent stress test: flipping a single token flips the label, so the network must carry an exact running state across the whole sequence. There is no shortcut β a model that ignores any time step cannot beat 50%.
The vanishing gradient problemβ
A vanilla RNN computes . Backpropagating the loss at step to step multiplies many Jacobians together:
Because and typically has spectral radius below 1, this product shrinks exponentially with . Early-step gradients vanish, and the network never learns long-range dependencies. (When the norm is instead above 1, the same product explodes.)
The LSTM: gating to the rescueβ
The Long Short-Term Memory cell adds a second piece of state β the cell state β and three sigmoid gates that decide what to keep, write, and read. The full carry is the pair .
Here is the logistic sigmoid, is elementwise multiplication, and each gate is a vector in .
Why this fixes vanishing gradients. The cell update is additive: . The gradient flowing back through the cell state is
When the forget gate stays near 1, this is close to the identity, so gradients travel across many steps without shrinking β a "constant error carousel." The network learns how long to remember by controlling , instead of being forced to forget by the geometry of repeated matrix products.
Building the model in Flax NNXβ
Flax NNX separates the cell (one step of recurrence) from the layer (the scan over time). nnx.RNN wraps any cell and applies it across the time axis of a (B, T, features) input, returning per-step outputs (B, T, hidden). We embed integer tokens, run the RNN, and classify from the last step.
import jax
import jax.numpy as jnp
from flax import nnx
import optax
class LSTMClassifier(nnx.Module):
def __init__(self, vocab, embed, hidden, n_classes, *, rngs: nnx.Rngs):
self.embed = nnx.Embed(vocab, embed, rngs=rngs)
self.rnn = nnx.RNN(nnx.LSTMCell(embed, hidden, rngs=rngs))
self.head = nnx.Linear(hidden, n_classes, rngs=rngs)
def __call__(self, tokens):
h = self.embed(tokens) # (B, T) -> (B, T, embed)
h = self.rnn(h) # (B, T, embed) -> (B, T, hidden)
return self.head(h[:, -1]) # last step -> (B, n_classes)
Swapping the cell: GRU and vanilla RNNβ
The cell is the only thing that changes. A GRU (nnx.GRUCell) merges the forget and input gates into a single update gate and carries just h β fewer parameters, often comparable accuracy. A vanilla RNN (nnx.SimpleCell) has no gates at all and is our vanishing-gradient baseline.
class GRUClassifier(nnx.Module):
def __init__(self, vocab, embed, hidden, n_classes, *, rngs: nnx.Rngs):
self.embed = nnx.Embed(vocab, embed, rngs=rngs)
self.rnn = nnx.RNN(nnx.GRUCell(embed, hidden, rngs=rngs)) # gated, single carry h
self.head = nnx.Linear(hidden, n_classes, rngs=rngs)
def __call__(self, tokens):
h = self.embed(tokens)
h = self.rnn(h)
return self.head(h[:, -1])
class VanillaRNNClassifier(nnx.Module):
def __init__(self, vocab, embed, hidden, n_classes, *, rngs: nnx.Rngs):
self.embed = nnx.Embed(vocab, embed, rngs=rngs)
self.rnn = nnx.RNN(nnx.SimpleCell(embed, hidden, rngs=rngs)) # no gates
self.head = nnx.Linear(hidden, n_classes, rngs=rngs)
def __call__(self, tokens):
h = self.embed(tokens)
h = self.rnn(h)
return self.head(h[:, -1])
Bidirectional: reading both waysβ
For tasks where the whole sequence is available (classification, tagging), a bidirectional RNN runs one cell forward and another backward, then concatenates their outputs. Each position now sees both past and future context. nnx.Bidirectional handles the reversal, scan, and merge; the classifier head just needs 2 * hidden inputs.
class BiLSTMClassifier(nnx.Module):
def __init__(self, vocab, embed, hidden, n_classes, *, rngs: nnx.Rngs):
self.embed = nnx.Embed(vocab, embed, rngs=rngs)
forward = nnx.RNN(nnx.LSTMCell(embed, hidden, rngs=rngs))
backward = nnx.RNN(nnx.LSTMCell(embed, hidden, rngs=rngs))
self.birnn = nnx.Bidirectional(forward, backward) # concatenates fwd ++ bwd
self.head = nnx.Linear(2 * hidden, n_classes, rngs=rngs)
def __call__(self, tokens):
h = self.embed(tokens) # (B, T, embed)
h = self.birnn(h) # (B, T, 2*hidden)
return self.head(h[:, -1])
Under the hood: carry threading with nnx.scanβ
nnx.RNN is a convenience wrapper around a scan. Writing that scan by hand is the clearest way to understand carry threading β the mechanism that passes the recurrent state from one step to the next.
The pattern: initialize the carry with cell.initialize_carry, then decorate a per-step function with @nnx.scan. The special nnx.Carry marker in in_axes/out_axes says "this argument is threaded, not sliced"; the integer 1 says "slice/stack this argument along the time axis."
def manual_lstm_scan(cell: nnx.LSTMCell, sequence):
batch = sequence.shape[0]
in_features = sequence.shape[-1]
carry = cell.initialize_carry((batch, in_features), nnx.Rngs(0)) # (h, c), each (B, hidden)
@nnx.scan(in_axes=(nnx.Carry, 1), out_axes=(nnx.Carry, 1))
def step(carry, x_t):
carry, y_t = cell(carry, x_t) # ((h, c), x_t) -> ((h, c), y_t)
return carry, y_t
carry, outputs = step(carry, sequence) # carry: final (h, c); outputs: (B, T, hidden)
return carry, outputs
This produces bit-for-bit the same per-step outputs as nnx.RNN(cell)(sequence) β the wrapper is exactly this scan plus some ergonomics (masking, reversal, time-major handling). For an LSTM the carry is the tuple (h, c); for a GRU or vanilla RNN it is just h.
The training stepβ
Standard NNX training: cross-entropy on the last-step logits, nnx.value_and_grad with has_aux=True to also return logits for the accuracy metric, and optimizer.update(model, grads).
from shared.training_utils import compute_accuracy, compute_cross_entropy_loss
@nnx.jit
def train_step(model, optimizer, batch):
def loss_fn(model):
logits = model(batch["x"])
loss = compute_cross_entropy_loss(logits, batch["y"])
return loss, logits
(loss, logits), grads = nnx.value_and_grad(loss_fn, has_aux=True)(model)
optimizer.update(model, grads)
acc = compute_accuracy(logits, batch["y"])
return loss, acc
Build the model and optimizer explicitly, then loop:
model = LSTMClassifier(vocab=10, embed=32, hidden=64, n_classes=2, rngs=nnx.Rngs(0))
optimizer = nnx.Optimizer(model, optax.adam(1e-2), wrt=nnx.Param)
for step in range(30):
loss, acc = train_step(model, optimizer, {"x": x_batch, "y": y_batch})
The dataset is generated on the fly β no downloads:
key = jax.random.key(0)
x = jax.random.randint(key, (512, 12), 0, 10) # tokens in [0, 10)
y = (x.sum(axis=1) % 2).astype(jnp.int32) # parity label
Results / What to Expectβ
Parity is fully learnable by a gated recurrent network. On CPU, the LSTM drives cross-entropy toward zero and hits 100% accuracy within ~30 steps:
$ python sequence/rnn_cells.py
model=lstm samples=512 seq_len=12 epochs=40 batch=128
epoch 0 loss 0.7112 acc 0.4844
epoch 4 loss 0.6722 acc 0.5938
epoch 9 loss 0.5305 acc 0.7422
epoch 19 loss 0.0337 acc 0.9922
epoch 39 loss 0.0006 acc 1.0000
manual scan outputs (4, 12, 64) carry h (4, 64) c (4, 64)
Try MODEL=gru or MODEL=bilstm (both solve it), and MODEL=rnn for the vanilla baseline β the nnx.SimpleCell struggles as you push seq_len higher, exactly the vanishing-gradient behavior the gates were designed to cure. Environment knobs EPOCHS, BATCH, and SYNTHETIC let you scale the run.
Common Pitfallsβ
-
β Feeding
(B, features)tonnx.RNNand wondering why it errors. βnnx.RNNexpects a time axis: shape(B, T, features). Embed first, then scan. -
β Classifying from
h[:, 0]or averaging all steps for a task that needs the full sequence. β For parity, use the last steph[:, -1](or a bidirectional final state) so the state has seen every token. -
β Building
LSTMCell(embed, hidden)but wiring the head fromembed. β The RNN outputshidden-dim vectors; the head isnnx.Linear(hidden, n_classes)(and2 * hiddenfor bidirectional). -
β Expecting a vanilla
nnx.SimpleCellto match the LSTM on long sequences. β Gates exist precisely to carry gradients across time; reach forLSTMCell/GRUCellwhen dependencies are long. -
β Putting a Python list of cells in a plain attribute to stack RNN layers. β On Flax 0.12 wrap submodule lists in
nnx.List([...])(and dicts innnx.Dict({...})) so they register as state.
Next stepsβ
- Simple Transformer β attention replaces recurrence and parallelizes across time.
- Graph Neural Networks β message passing generalizes recurrence to arbitrary graphs.
Complete Exampleβ
Full runnable script with all four model variants, the manual scan, and the training loop: examples/sequence/rnn_cells.py.
Referencesβ
- Hochreiter & Schmidhuber (1997), Long Short-Term Memory β Neural Computation 9(8).
- Cho et al. (2014), Learning Phrase Representations using RNN EncoderβDecoder (GRU) β arXiv:1406.1078.
- Pascanu, Mikolov & Bengio (2013), On the Difficulty of Training Recurrent Neural Networks β arXiv:1211.5063.
- Chung et al. (2014), Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling β arXiv:1412.3555.
- Greff et al. (2015), LSTM: A Search Space Odyssey β arXiv:1503.04069.