Skip to content

Fix RNN unrolling gradients and bidirectional LSTMs - #646

Open
seanmor5 wants to merge 7 commits into
mainfrom
sm-rnn-fixes
Open

Fix RNN unrolling gradients and bidirectional LSTMs#646
seanmor5 wants to merge 7 commits into
mainfrom
sm-rnn-fixes

Conversation

@seanmor5

@seanmor5 seanmor5 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

RNN layers in Axon have been learning from the wrong gradients. When an RNN is unrolled the "dynamic" way, it becomes an Nx while loop. Nx figures out the gradient of a while loop 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 :dynamic is 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>_forward and <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:

            accuracy   loss   val_accuracy   val_loss
keras         0.9151  0.2263       0.8428      0.3650
this example  0.9201  0.3012       0.8644      0.3436

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/3 today, 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

seanmor5 and others added 7 commits August 15, 2026 12:24
`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 polvalente left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but please validate if I fixed things correctly!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants