Fix RNN unrolling gradients and bidirectional LSTMs - #646
Open
seanmor5 wants to merge 7 commits into
Open
Conversation
`dynamic_unroll/7` compiled to a plain Nx `while`. Nx's reverse-mode
rule for `while` chains the per-step Jacobians in forward time order,
which agrees with reverse-mode only when they commute. RNN cell
Jacobians do not commute, so every dynamically unrolled LSTM, GRU and
ConvLSTM trained on wrong gradients — silently, and divergently from
the same model unrolled statically.
Checked against central finite differences on a GRU, d/d whn[0,0]:
numerical 0.09108
static 0.09149
dynamic 0.40509 <- before
dynamic 0.09149 <- after
`:dynamic` is the default `:unroll` for the RNN layers, so this
affected RNN training out of the box.
The loop now saves the carry entering each step on the way forward and
installs a `custom_grad` that walks the sequence in reverse, taking a
per-step VJP — the correct reverse-mode order. The output buffer also
takes its element type from the cell's output rather than a hardcoded
f32 zero, so f64 models no longer fail to build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs, both visible when wrapping an LSTM:
`bidirectional/4` un-reversed every leaf of the backward run's output.
That is right for per-step outputs, but an LSTM also returns its final
`{cell, hidden}` state, whose `:axis`-th dimension is the hidden size
and not time — reversing it scrambled the hidden state along its
feature axis. Only leaves matching the input's rank and time dimension
are un-reversed now, so the merged state is each direction's final
state, as in Keras' `Bidirectional(LSTM(...))`.
Both directions were also built from a single `Axon.block`, which
shares parameters across invocations, so the forward and backward
weights were tied and the layer had half its intended capacity. Each
direction now gets its own block. On the Keras IMDB model this brings
the trainable parameter count from 2,658,945 to 2,757,761, matching
Keras exactly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An Axon port of the model from https://keras.io/examples/nlp/bidirectional_lstm_imdb/, used to validate the RNN unrolling and bidirectional fixes end to end. The model matches Keras layer for layer at 2,757,761 trainable parameters and trains to a 0.3816 validation loss against Keras' 0.3650. `prepare_imdb.py` converts Keras' `imdb.npz` — which stores the reviews as pickled Python object arrays Nx cannot read — into flat binaries, applying exactly the preprocessing `imdb.load_data(num_words=20000)` and `pad_sequences(maxlen=200)` perform. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The example converged more slowly than the Keras original — by the
second epoch its training loss was 0.4363 against Keras' 0.2263, i.e.
it was fitting the training set less aggressively rather than
generalizing worse. Three initializer differences accounted for it,
none of them expressible through `Axon.lstm/3` today, so the example
now rewrites the initialized parameters before training starts.
Keras keeps the input and recurrent kernels as single
`{input_dim, 4 * units}` and `{units, 4 * units}` matrices and slices
them per gate, so its initializer sees the full width. Axon stores one
tensor per gate and initializes each separately, which widens the
glorot limit and leaves the four recurrent blocks non-orthogonal to one
another. `KerasLSTMInit` draws the wide matrix once and slices it, uses
an orthogonal recurrent kernel, and starts the forget gate's bias at
one so the gate begins open.
Verified on the rewritten state: the concatenated recurrent kernel
satisfies `max |W Wt - I| = 4.8e-7`, the input kernel's limit matches
glorot over the full width, `bf` is one with the other three biases
zero, and the values survive the loop's init.
This closes the gap:
accuracy loss val_accuracy val_loss
keras 0.9151 0.2263 0.8428 0.3650
before 0.8447 0.4363 0.8334 0.3816
after 0.9201 0.3012 0.8644 0.3436
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
polvalente
approved these changes
Aug 15, 2026
polvalente
left a comment
Member
There was a problem hiding this comment.
LGTM, but please validate if I fixed things correctly!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
RNN layers in Axon have been learning from the wrong gradients. When an RNN is unrolled the "dynamic" way, it becomes an Nx
whileloop. Nx figures out the gradient of awhileloop by combining each step in the order the steps happened going forward. That shortcut only gives the right answer when the steps can be reordered without changing the result, and RNN steps cannot be reordered. So the gradients that came back out were simply wrong. Because:dynamicis the default, this hit every LSTM, GRU, and ConvLSTM unless someone went out of their way to pick:static. The fix is to remember the state going into each step on the way forward, then walk the sequence backward afterward and work out the gradient one step at a time, which is the order reverse-mode actually calls for.To be sure this was a real bug and not just two versions disagreeing, I checked both against finite differences, which nudges a weight by a tiny amount and measures how much the loss moves. For one GRU weight the true answer was 0.09108. The static unroll said 0.09149, so it was right all along. The dynamic unroll said 0.40509, which is more than four times too big. After the fix, the dynamic unroll also says 0.09149. While rewriting that loop I also fixed a smaller problem where the output buffer was always created as f32, which made f64 models fail to build at all.
There were two more bugs in
Axon.bidirectional, and both show up when you wrap an LSTM. A bidirectional layer runs the sequence forward and also backward, then has to flip the backward results around so they line up with the forward ones. The old code flipped every single thing that came back. That is correct for the per-step outputs, but an LSTM also hands back its final cell and hidden state, and those do not have a time dimension at all. Flipping them scrambled the hidden state along its feature dimension. Now only the leaves whose shape actually matches the input's time dimension get flipped, and the state is passed along untouched, which is what Keras does.The other bidirectional bug is that both directions were built from a single
Axon.block. A block shares its parameters everywhere it is used, so the forward and backward LSTMs were secretly sharing one set of weights instead of each learning their own. That quietly cut the layer's capacity in half. Each direction now gets its own block, named<name>_forwardand<name>_backward. On the Keras IMDB model this brings the trainable parameter count from 2,658,945 up to 2,757,761, which is exactly the number Keras reports.To check all of this end to end, this also adds an Axon port of the Keras bidirectional LSTM IMDB example, matching it layer for layer, along with a small Python script that converts the Keras IMDB data into a format Nx can read. It now trains at least as well as the Keras original:
Getting there took one more piece of work that is not a bug fix. At first the example converged more slowly than Keras, and the giveaway was the training loss rather than the validation loss, which meant our model was fitting the training data less hard rather than generalizing worse. The cause was that Keras sets up its LSTM weights differently than Axon does. Keras keeps the input weights and the recurrent weights as one wide matrix each and cuts them into four pieces, one per gate, so the random draw sees the whole width at once. Axon keeps a separate tensor per gate and draws each one on its own, which makes the numbers a bit bigger than Keras' and means the four recurrent pieces are not orthogonal to each other. Keras also starts the forget gate's bias at one instead of zero, so that gate begins open and gradients can travel back through time right away. None of that can be expressed through
Axon.lstm/3today, so the example draws the wide matrix once and slices it, and sets the forget bias, rewriting the parameters before training starts. I checked the rewritten weights directly: the recurrent matrix is orthogonal to within 4.8e-7, the input weights match glorot over the full width, only the forget bias is one, and the values survive the training loop's own setup.There are five new tests: gradient agreement between the two unrolls for GRU and for LSTM recurrent kernels, a finite-difference check anchoring the dynamic gradient to the true answer, an f64 check, and a test that the bidirectional state comes back un-reversed the way Keras does it. The full suite passes at 827 tests.
🤖 Generated with Claude Code