← All posts

Foundations

Post 8 of 15

Recurrent networks and vanishing gradients

Posts in this series · 15

Every network in this series so far takes one fixed-size input and gives one answer. The tiny network from post 1 takes exactly two numbers. The convolutions in post 7 slide a small filter over an image or a sound, but the filter itself still only sees a fixed window.

Language and speech do not come like that. They come in order, one piece after another, and the number of pieces changes every time. This post builds the oldest answer to that problem, the recurrent neural network (RNN), runs it by hand, and then shows exactly why it struggles to remember anything for long. That struggle, the vanishing gradient, is the reason the next post on LSTM and GRU exists, and part of the reason attention took over.

The whole model at a glance

Here is a whole recurrent network at work before we build it piece by piece. The task: read the word “hell” one character at a time and, at every step, predict the next character. So the targets are “ello”. The network is tiny (a hidden state of 3 numbers, with weights trained offline and rounded to one decimal), small enough to show every number.

Watch steps 3 and 4. Both read the letter “l”, but the first must predict “l” and the second must predict “o”. The input alone cannot tell them apart; only the hidden state, the network’s memory of what came before, can.

An RNN unrolled over four characters, from one-hot inputs through hidden states to next-character probabilities
Figure 1. An RNN from input to output. Each step mixes the current character with the previous hidden state, then reads the new hidden state out as next-character probabilities. The two “l” inputs give different predictions because the memory differs. The last steps add up the loss and generate “hello” from “h”.

Sequences: when order matters

A sequence is a list where position carries meaning. Three examples, all of which matter for speech models:

  1. A sentence. “The dog bit the man” and “The man bit the dog” use the same five words. Only the order differs, and the meaning flips.
  2. A waveform. As the audio basics post showed, sound is a long list of air-pressure samples, often 16,000 or 24,000 per second. Shuffle them and you get noise.
  3. Speech frames. Speech models usually chop audio into short frames (for example 25 milliseconds each, every 10 milliseconds) and describe each frame with a few dozen numbers, such as a row of a mel spectrogram. A one-second word becomes about 100 frames; a ten-second sentence becomes about 1,000.

The everyday picture: reading a book. You cannot understand page 50 from page 50 alone. You carry a summary of the story so far in your head, and each new page updates it.

Why a plain network cannot read a sequence

Suppose we try the simplest thing: glue all the frames of a recording into one long list and feed it to an ordinary network like the one from post 1.

What we want is a network that looks at one step at a time, uses the same weights at every step, and keeps a running summary of what it has seen. That is the recurrent idea.

The recurrent idea: a hidden state carried forward

In one sentence: at every step, mix the new input with the memory from the previous step, squash the result, and call that the new memory.

The memory is called the hidden state, written hth_t (“h at time t”). It starts at zero before the first input, h0=0h_0 = 0.

The scalar version first

Start with the smallest possible recurrent network: one hidden unit, so the hidden state is a single number, and one number of input per step.

ht=tanh⁡(wx xt+wh ht−1+b)h_t = \tanh\left(w_x \, x_t + w_h \, h_{t-1} + b\right)

This is exactly one neuron from post 1, with two inputs. The twist is that one of its inputs is its own previous output. That loop is the “recurrent” in the name.

The everyday picture: a jar of water that you top up each minute. whw_h is how much of the old water you keep (the rest leaks away), wxw_x is how much of the new pour goes in, and tanh⁡\tanh is the jar’s rim: it can never hold more than “full”, however much you pour.

Running it by hand

Take wx=1w_x = 1, wh=0.5w_h = 0.5, b=0b = 0, and the five-step input sequence x=(1, 0.5, −1, 0, 0)x = (1,\ 0.5,\ -1,\ 0,\ 0).

Step 1. The memory starts empty, h0=0h_0 = 0.

z1=1×1+0.5×0+0=1,h1=tanh⁡(1)=0.7616z_1 = 1 \times 1 + 0.5 \times 0 + 0 = 1, \qquad h_1 = \tanh(1) = 0.7616

Step 2. Now the memory is 0.76160.7616, and it gets mixed with the new input 0.50.5.

z2=1×0.5+0.5×0.7616=0.5+0.3808=0.8808,h2=tanh⁡(0.8808)=0.7068z_2 = 1 \times 0.5 + 0.5 \times 0.7616 = 0.5 + 0.3808 = 0.8808, \qquad h_2 = \tanh(0.8808) = 0.7068

Step 3. A negative input pulls the memory down.

z3=1×(−1)+0.5×0.7068=−1+0.3534=−0.6466,h3=tanh⁡(−0.6466)=−0.5694z_3 = 1 \times (-1) + 0.5 \times 0.7068 = -1 + 0.3534 = -0.6466, \qquad h_3 = \tanh(-0.6466) = -0.5694

Step 4. No new input. The memory is just halved and squashed.

z4=0+0.5×(−0.5694)=−0.2847,h4=−0.2772z_4 = 0 + 0.5 \times (-0.5694) = -0.2847, \qquad h_4 = -0.2772

Step 5. Halved again.

z5=0.5×(−0.2772)=−0.1386,h5=−0.1377z_5 = 0.5 \times (-0.2772) = -0.1386, \qquad h_5 = -0.1377

Look at steps 4 and 5. With no input, the memory roughly halves each step, because wh=0.5w_h = 0.5 and tanh⁡\tanh barely bends small numbers. Hold that thought: it is the whole story of this post in miniature.

The same neuron drawn five times, passing its memory from step to step
Figure 2. An RNN, unrolled. One neuron reused five times. With the defaults (w_x = 1, w_h = 0.5, b = 0, inputs 1, 0.5, −1, 0, 0) the hidden states are 0.7616, 0.7068, −0.5694, −0.2772, −0.1377. The same three weights are highlighted at every step.

What would change? Raise whw_h to 0.9 and the memory leaks more slowly. Set wh=0w_h = 0 and the network has no memory at all: each hth_t depends only on xtx_t. Set wx=0w_x = 0 and the network ignores its inputs entirely.

The vector version

One number is a very small memory. Real RNNs keep a vector of hidden numbers, say 256 of them, and read a vector input, say 80 mel numbers per frame. The formula keeps its shape; the single weights become weight matrices (grids of weights, one per pair of connected numbers).

ht=tanh⁡(Wx xt+Wh ht−1+b)\mathbf{h}_t = \tanh\left(W_x \, \mathbf{x}_t + W_h \, \mathbf{h}_{t-1} + \mathbf{b}\right)

A tiny example with two hidden units and a one-number input. Take Wx=(10.5)W_x = \begin{pmatrix} 1 \\ 0.5 \end{pmatrix}, Wh=(0.60.20.20.6)W_h = \begin{pmatrix} 0.6 & 0.2 \\ 0.2 & 0.6 \end{pmatrix}, b=0\mathbf{b} = 0, h0=(0,0)\mathbf{h}_0 = (0, 0), inputs x1=1x_1 = 1, x2=0x_2 = 0.

Step 1: Wxx1=(1,0.5)W_x x_1 = (1, 0.5), and Whh0=(0,0)W_h \mathbf{h}_0 = (0, 0), so h1=(tanh⁡1,tanh⁡0.5)=(0.7616, 0.4621)\mathbf{h}_1 = (\tanh 1, \tanh 0.5) = (0.7616,\ 0.4621).

Step 2: the input adds nothing, and

Whh1=(0.6×0.7616+0.2×0.4621,  0.2×0.7616+0.6×0.4621)=(0.5494, 0.4296)W_h \mathbf{h}_1 = (0.6 \times 0.7616 + 0.2 \times 0.4621,\ \ 0.2 \times 0.7616 + 0.6 \times 0.4621) = (0.5494,\ 0.4296)

so h2=(0.5001, 0.4050)\mathbf{h}_2 = (0.5001,\ 0.4050). Each unit’s new value depends on both old values. That mixing is the only real difference from the scalar case, and we will come back to it when we look at the matrix case.

Unrolling through time

Figure 2 already did the key trick: it drew the one neuron five times in a row. This is called unrolling the network through time. The unrolled picture looks like a five-layer network, with one crucial difference: every layer has the same weights. wxw_x, whw_h and bb are three numbers, not fifteen.

Two consequences follow, and they are the two halves of this post:

One output per step, or one at the end

The hidden state is memory, not an answer. To get an answer, add an output layer, for example yt=wo hty_t = w_o \, h_t. There are two common shapes:

The loss (from post 2) is then either one number at the end, or a sum of one loss per step.

A tiny task where memory fades

Here is the simplest memory test I can think of. Feed the network one important number at step 1, then zeros. At the end, ask: “what was the first number?”

x=(s, 0, 0, …, 0),target at the last step=sx = (s,\ 0,\ 0,\ \dots,\ 0), \qquad \text{target at the last step} = s

where ss is either +1+1 or −1-1. To answer, the final hidden state must still “know” the sign of ss after all those zeros.

With wx=1w_x = 1, b=0b = 0 and the input s=1s = 1, step 1 gives h1=tanh⁡(1)=0.7616h_1 = \tanh(1) = 0.7616, as before. After that every input is zero, so each step is just ht=tanh⁡(wh ht−1)h_t = \tanh(w_h \, h_{t-1}).

With wh=0.5w_h = 0.5 over ten steps:

step12345678910
hth_t0.76160.36340.17970.08960.04480.02240.01120.00560.00280.0014

By step 10 the memory is 0.0014. With s=−1s = -1 everything is mirrored, so the final state is −0.0014-0.0014. The whole difference between “the first input was +1” and “the first input was −1” is now 0.0028. Any noise would drown it.

A fair question: why not just make whw_h bigger? Here is the final state after ten steps, for s=+1s = +1, at several values of whw_h:

whw_h0.50.91.01.53.0
h10h_{10}0.00140.18920.35630.85850.9949

With wh=1.5w_h = 1.5 or 33 the memory holds. It even grows at first: with wh=3w_h = 3, h2=tanh⁡(3×0.7616)=0.9795h_2 = \tanh(3 \times 0.7616) = 0.9795. The tanh⁡\tanh rim stops it at just under 1. So the forward memory can be kept by a big recurrent weight. The catch, as we will see, is in the backward pass: learning needs gradients, and a saturated tanh⁡\tanh passes almost no gradient.

Hidden state over ten steps after a first input of plus or minus one
Figure 3. How long does the first input last? The shaded gap is everything the network still knows about step 1. At w_h = 0.5 it is 0.0028 after ten steps; at 0.9 it is 0.3785; at 1.5 it is 1.7171. Drag the slider and watch the gap collapse or hold.

Backpropagation through time

To train an RNN we need the gradient of the loss with respect to wxw_x, whw_h and bb, just as in post 1. The method has a name, backpropagation through time (BPTT), but it is nothing new: unroll the network into its stack of identical layers, then run ordinary backpropagation through that stack. Werbos (1990) named and described it, and Rumelhart, Hinton and Williams (1986) already showed that a recurrent network can be trained by unfolding it into a layered one.

Because the weights are shared, each weight collects a gradient contribution from every step, and the contributions are added up. That part is bookkeeping. The interesting question is: how much does the hidden state at the last step depend on the hidden state at the first step? That number decides whether a mistake at the end can ever teach the network about something it saw at the start.

In one sentence: a small nudge to ht−1h_{t-1} reaches hth_t after being multiplied by whw_h and then by the slope of tanh⁡\tanh at that step.

∂ht∂ht−1=wh×tanh⁡′(zt)=wh×(1−ht2)\frac{\partial h_t}{\partial h_{t-1}} = w_h \times \tanh'(z_t) = w_h \times \left(1 - h_t^2\right)

The chain rule from post 1 says: to go back several links, multiply the link factors.

∂h5∂h1=∂h5∂h4×∂h4∂h3×∂h3∂h2×∂h2∂h1\frac{\partial h_5}{\partial h_1} = \frac{\partial h_5}{\partial h_4} \times \frac{\partial h_4}{\partial h_3} \times \frac{\partial h_3}{\partial h_2} \times \frac{\partial h_2}{\partial h_1}

Let us work it for the run in Figure 2 (wh=0.5w_h = 0.5, hidden states 0.7616, 0.7068, −0.5694, −0.2772, −0.13770.7616,\ 0.7068,\ -0.5694,\ -0.2772,\ -0.1377).

linkhth_t1−ht21 - h_t^2factor =0.5×(1−ht2)= 0.5 \times (1 - h_t^2)running product
step 2 to step 10.70680.50040.25020.2502
step 3 to step 2−0.56940.67580.33790.0845
step 4 to step 3−0.27720.92310.46160.0390
step 5 to step 4−0.13770.98100.49050.0191

Four links, and the gradient from step 5 back to step 1 is 0.0191: less than 2 percent survives. Every factor is below 1, because wh=0.5w_h = 0.5 and the tanh⁡\tanh slope can only make things smaller.

Now two other settings, same inputs:

Hidden states with backward arrows; each link multiplies the gradient by one factor
Preset
Figure 4. The gradient, walked backwards. Each link multiplies by w_h × (1 − h²). Defaults (w_x = 1, w_h = 0.5) end at 0.0191. Presets: “explode” (w_x = 0.01, w_h = 1.5) ends at 5.0437; “saturate” (w_x = 1, w_h = 3) ends at 0.00001.

Vanishing and exploding gradients

Strip away the tanh⁡\tanh slopes (pretend they are all 1, the best case) and every link factor is just whw_h. Going back kk steps multiplies by whw_h kk times:

∂ht∂ht−k≈wh k\frac{\partial h_t}{\partial h_{t-k}} \approx w_h^{\,k}

Repeated multiplication is ruthless:

The everyday picture is a whispering game passed along a line of children. If each child repeats the message at half volume, the twentieth hears nothing. If each one shouts a little louder than they heard, the twentieth is deafening. Only a factor of exactly 1 passes the message unchanged, and a network has no reason to land exactly there.

This is the vanishing gradient problem (factors below 1) and the exploding gradient problem (factors above 1). Hochreiter analysed it in his 1991 diploma thesis, and Bengio, Simard and Frasconi (1994) showed that for this kind of network, storing information robustly and learning long-range dependencies with gradient descent pull against each other. Their conclusion, in plain words: the further back the cause, the weaker its gradient, so plain RNNs learn short-range patterns and miss long-range ones.

What does each failure look like in practice?

Notice also that tanh⁡\tanh only ever helps vanishing. Its slope is at most 1, so it can shrink a factor but never grow it. With tanh⁡\tanh, explosion needs whw_h above 1 and hidden states near 0.

Gradient size against steps back on a logarithmic scale, vanishing or exploding
tanh slopes
Figure 5. Vanish or explode. With tanh slopes off, the curve is w_h to the power k: a straight line on a log axis. At w_h = 0.5, step 20 reads 0.00000095; at 0.9, step 30 reads 0.042; at 1.5, step 20 reads 3,325. Only w_h = 1 stays flat.

The matrix case and the largest stretch

With a hidden vector, each link factor is a matrix: WhW_h, with each row scaled by its unit’s tanh⁡\tanh slope. Going back kk steps multiplies by (roughly) WhW_h kk times. What plays the role of “is whw_h above or below 1” for a matrix?

The gentle answer: most matrices have a few special directions where multiplying just stretches the vector without turning it. The stretch factors are called eigenvalues. After many multiplications, the direction with the biggest stretch wins, and the vector’s length grows or shrinks like that biggest stretch to the power kk. The size of the largest eigenvalue is called the spectral radius.

Our two-unit Wh=(0.60.20.20.6)W_h = \begin{pmatrix} 0.6 & 0.2 \\ 0.2 & 0.6 \end{pmatrix} has eigenvalues 0.8 (direction (1,1)(1, 1): 0.6+0.2=0.80.6 + 0.2 = 0.8) and 0.4 (direction (1,−1)(1, -1): 0.6−0.2=0.40.6 - 0.2 = 0.4). Start with (1,0)(1, 0) and multiply repeatedly:

times multiplied12345
vector(0.6, 0.2)(0.4, 0.24)(0.288, 0.224)(0.2176, 0.192)(0.1690, 0.1587)
length0.63250.46650.36490.29020.2318

The vector swings round to point along (1,1)(1, 1), the direction of the larger stretch, and from then on its length shrinks by about 0.8 each time. Change the matrix to (1.20.30.31.2)\begin{pmatrix} 1.2 & 0.3 \\ 0.3 & 1.2 \end{pmatrix}, whose eigenvalues are 1.5 and 0.9, and after 10 multiplications (1,0)(1, 0) has length 40.8: the 1.5 direction takes over and explodes.

So the rule of thumb carries over: largest stretch below 1, gradients vanish; above 1, they can explode (this is the condition Pascanu, Mikolov and Bengio (2013) make precise; the exact statement is about the tanh⁡\tanh-scaled matrices, and “below 1” is sufficient for vanishing while “above 1” is only necessary for explosion).

An arrow repeatedly multiplied by a matrix, turning towards its strongest stretch
Preset
Figure 6. Repeated multiplication by a matrix. With a = 0.6, c = 0.2 the stretches are 0.8 and 0.4; the arrow turns towards the 0.8 direction and shrinks: length 0.6325, 0.4665, 0.3649, 0.2902, 0.2318. Set a = 1.2, c = 0.3 and the length reaches 40.8 after ten steps.

Remedies

Nothing below makes a plain RNN remember perfectly. Each one attacks one side of the problem.

Gradient clipping, for explosions

In one sentence: if the gradient vector is longer than a chosen limit, shrink it to that length, keeping its direction.

g←g×min⁡(1, c∥g∥)\mathbf{g} \leftarrow \mathbf{g} \times \min\left(1,\ \frac{c}{\lVert \mathbf{g} \rVert}\right)

Worked: g=(3,4)\mathbf{g} = (3, 4) and c=1c = 1. The length is 9+16=5\sqrt{9 + 16} = 5. Since 5 is more than 1, scale by 1/51/5: the clipped gradient is (0.6,0.8)(0.6, 0.8), length 1, same direction. With g=(6,8)\mathbf{g} = (6, 8) and c=5c = 5: length 10, scale by 5/105/10, result (3,4)(3, 4). With g=(0.3,0.4)\mathbf{g} = (0.3, 0.4) and c=1c = 1: length 0.5, below the threshold, unchanged.

The everyday picture: a speed limiter on a car. You still steer where you want to go, but you cannot take a single wild leap off a cliff edge in the loss landscape. Pascanu, Mikolov and Bengio (2013) proposed exactly this rescale-by-norm rule for RNNs. It is now standard far beyond RNNs; most Transformer training recipes clip at a norm of 1.

Clipping fixes explosions only. A vanishing gradient is already tiny, and clipping never makes anything bigger.

A gradient arrow shortened onto a circle of chosen radius, keeping its direction
Presets
Figure 7. Clip by norm. Default gradient (3, 4), threshold 1: length 5, scale 0.2, clipped (0.6, 0.8). Drag the tip inside the circle and the scale reads 1: nothing changes.

Careful initialisation, for vanishing

Training starts from the initial weights, and if the starting whw_h already makes the gradient vanish, there is no signal to learn from. So start where the factor is near 1. In the trainer below, changing only the starting whw_h from 0.5 to 1.0 turns a delay the network could never learn into one it learns in 5 passes. For vector RNNs the same idea means starting WhW_h with stretches near 1, for example as an orthogonal matrix (one that rotates but never stretches), or as the identity matrix with ReLU units (Le, Jaitly and Hinton, 2015).

This helps at the start, but training is free to move WhW_h away from that sweet spot.

Gated units, the real fix

The lasting fix changes the cell itself. Instead of forcing memory through “multiply by WhW_h, then squash” at every step, an LSTM (long short-term memory, Hochreiter and Schmidhuber, 1997) keeps a separate memory lane that is updated mostly by adding, with learned gates deciding what to keep and what to write. When the “keep” gate is near 1, the backward factor along that lane is near 1, and the whisper survives. The GRU (gated recurrent unit, Cho et al., 2014) is a lighter version of the same idea. That is the whole of the next post.

Truncated backpropagation through time

A practical point, not a cure. For a 1,000-frame recording, backpropagating through all 1,000 steps needs every hidden state stored and is slow. Truncated BPTT runs forward over the whole sequence, carrying the hidden state along, but only backpropagates through the last, say, 50 steps of each chunk (Williams and Peng, 1990). The network can still carry information further than 50 steps, but it can only learn dependencies up to the truncation length. Given how small the gradient is beyond a few dozen steps anyway, little is lost.

A tiny RNN that learns, or fails

Time to see all of this in one training run. The task is the memory test from earlier: input (s,0,0,…,0)(s, 0, 0, \dots, 0) with s=+1s = +1 or −1-1, and after TT steps the network must output ss. The delay TT is how many steps it must remember.

The network is the one-unit RNN with no bias, plus an output weight: ht=tanh⁡(wxxt+whht−1)h_t = \tanh(w_x x_t + w_h h_{t-1}) and y=wohTy = w_o h_T. The loss is half the squared error, averaged over the two examples s=+1s = +1 and s=−1s = -1. Every pass (epoch) computes the full gradient by BPTT and takes one gradient-descent step with learning rate η=0.5\eta = 0.5. There is no randomness at all: every run starts from wx=0.5w_x = 0.5, wh=0.5w_h = 0.5, wo=0.5w_o = 0.5, so anyone can reproduce the numbers exactly.

At the start, the network outputs almost 0 for both examples, so the loss is close to 12×12=0.5\tfrac{1}{2} \times 1^2 = 0.5. How big is the very first gradient for the input weight wxw_x, the only thing that can teach the network to “write” ss into memory?

delay TT15101520
first gradient for wxw_x−0.302−0.0226−0.00072−0.000022−0.0000007
passes until loss is below 0.016825225never (in 2,000)

Every extra 5 steps of delay cuts the first gradient by roughly 30 times, because the signal passes through 5 more factors of about 0.5. At T=20T = 20 the gradient is less than a millionth, and after 2,000 passes the loss is still 0.4999989: the network has learned nothing. Note that the forward signal fades too: at T=20T = 20 the untrained network’s final hidden state is only 0.00000086, the same fade as in Figure 3.

Now change one thing: start whw_h at 1.0 instead of 0.5. The delay-20 task is solved in 5 passes, and delay 30 in 4, with whw_h ending around 1.7 and 1.8. Starting the factor near 1 lets the gradient through, and gradient descent does the rest.

Do not read too much into the rescue: a one-unit network with a single bit to remember is the easiest possible case. In real tasks the network must remember some things and forget others, and a single whw_h cannot do both. That is exactly what gates are for.

A tiny recurrent network training to remember its first input, with its loss over passes
Training
Figure 8. Remember the first input. Defaults (T = 10, start w_h = 0.5, η = 0.5): loss 0.4996 at pass 1, below 0.01 at pass 25. Set T = 20 and it stays at 0.5 forever. Then set start w_h = 1.0: solved in 5 passes.

Recurrent networks in speech, and what replaced them

RNNs, mostly LSTMs and GRUs, were the workhorse of speech for most of the 2010s:

Then the Transformer (Vaswani et al., 2017) replaced them almost everywhere. The main reason is parallelism. An RNN cannot compute h10h_{10} until it has h9h_9, so a 1,000-frame recording takes 1,000 steps one after another, however many processors you have. Attention lets every position look at every other position directly, all at once, so training uses the whole GPU (graphics processing unit). And because any position can reach any other in a single hop, there is no long chain of factors for the gradient to vanish along. That is the subject of the attention post, and you can see it at work in Pocket TTS.

Recurrence has not disappeared, though. Generating speech in real time means producing one frame after another, and a Transformer has to look back over a growing history each time. A newer family of models, state-space models such as Mamba (Gu and Dao, 2023), carries a fixed-size state from step to step like an RNN, yet is designed so training can still run in parallel. The idea from this post, a small memory updated one step at a time, keeps coming back.

Counting the parameters

A recurrent layer with input size nn and hidden size mm has

nm+m2+mn m + m^2 + m

parameters.

The count does not depend on the sequence length: the same weights are reused at every step, which is the whole point of a recurrent network. The one-neuron toy (n=m=1n = m = 1) has 1+1+1=31 + 1 + 1 = 3. The “hell” model of Figure 1 has n=4n = 4 letters and m=3m = 3 hidden numbers: 12+9+3=12 + 9 + 3 = 24, plus an output layer from 3 hidden numbers to 4 letter scores, 3×4+4=163 \times 4 + 4 = 16, so 40 in all.

import torch.nn as nn

# the "hell" model: 4 letters in, 3 hidden numbers, 4 letter scores out
rnn = nn.RNN(input_size=4, hidden_size=3)
out = nn.Linear(3, 4)
for name, p in rnn.named_parameters():
    print(name, tuple(p.shape))
print("rnn", sum(p.numel() for p in rnn.parameters()))
print("output layer", sum(p.numel() for p in out.parameters()))

It prints:

weight_ih_l0 (3, 4)
weight_hh_l0 (3, 3)
bias_ih_l0 (3,)
bias_hh_l0 (3,)
rnn 27
output layer 16

PyTorch prints 27, not 24, because it keeps two bias vectors (bias_ih on the input side and bias_hh on the recurrent side). They are always added together, so the second one adds no expressive power; it is a convention inherited from older GPU libraries. The LSTM post counts the gated versions.

What to remember

Try it yourself

Sources