Deep Learning

Recurrent neural networks (RNN)

An RNN reads a sequence one step at a time and carries a memory forward, so the order of what it read changes what it decides.

On this page 7
  1. Why this had to be invented
  2. How it works
  3. The catch, and it is a big one
  4. Where you have already seen this
  5. Are RNNs dead?
  6. Remember this
  7. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

A recurrent neural network reads a sequence one item at a time and carries a memory of everything it has read so far.

Watch a shopkeeper add up your bill. He picks up each item, glances at the price, and says the new total out loud. He does not re-add the earlier items every time. He holds one number in his head and updates it.

That held number is the whole idea. In an RNN it is called the hidden state. It is a small set of numbers that carries forward what the network has read.

Each new item changes the running total. So does the order in which items arrive, once the update rule is anything more interesting than addition.

Why this had to be invented

An ordinary neural network takes a fixed number of inputs. Three clues in, one answer out. Sentences do not work like that.

"Chai" is one word. "The chai at the corner shop near the station is too sweet today" is fourteen. A network with three input slots has nowhere to put them.

You could try averaging all the words together into a fixed-size summary. That throws away the thing that carries the meaning.

"Dog bites man" and "man bites dog" contain identical words. Only one of them is news. Any method that ignores order cannot tell them apart.

How it works

The same small network is applied again and again, once per item, with its own previous answer fed back in.

   "not"          "very"          "good"
     |              |               |
     v              v               v
 [ cell ] --h1--> [ cell ] --h2--> [ cell ] --h3--> "negative"
     ^                                              (this is the answer)
     |
    h0 = empty memory

Every box is the same box. The same weights, reused at every step. That is what "recurrent" means: it comes back around.

h1, h2, h3 are the memory after each word. The final memory is the network's summary of the whole sentence, and the answer is read off from it.

The memory arrives before the next word is read. So "good" is understood in a state that already knows "not" came first.

The catch, and it is a big one

The memory is a fixed set of numbers. Every new word has to squeeze into the same space.

Push a long paragraph through and the early words get overwritten. By word two hundred, word one has faded almost to nothing. A human reading a long email holds the beginning loosely too — but not this badly.

This is called the vanishing gradient problem. The signal linking an early word to a late decision shrinks towards zero as the distance grows. It is not a bug in one implementation. It follows from applying the same small update over and over.

This part is confusing for almost everyone the first time. The short version: multiply a number smaller than one by itself forty times and it becomes almost nothing. The link between step 1 and step 40 does exactly that.

Two repairs followed. LSTM added gates that decide what to keep and what to throw away. Then transformers dropped the running memory altogether and let every word look directly at every other word.

Where you have already seen this

  • Your phone keyboard predicting the next word as you type.
  • Voice typing turning speech into text as you speak, before you finish the sentence.
  • Google Translate before 2017, which read a sentence into a memory and wrote it back out in another language.
  • Handwriting recognition on a touchscreen, where the order of your strokes matters.

Are RNNs dead?

For most text work, transformers replaced them. That is honest and worth saying plainly.

RNNs are still used where the input arrives as a live stream and you cannot wait for the end. Speech recognition on a device, sensor readings, keystroke prediction, small models on cheap hardware. Processing one step costs the same whether you are at word 5 or word 5000, which a transformer cannot claim.

The idea also came back in a new form. Several modern architectures are recurrent underneath, rebuilt so the steps can be computed in parallel during training.

Remember this

  • An RNN keeps a running memory and updates it one item at a time.
  • The same weights are reused at every step, which is why it handles any length.
  • Its weakness is long-range memory, and that weakness is what LSTMs and transformers were built to fix.

What to learn next

  • LSTM — the gated cell that made long-range memory workable.
  • Transformers — the architecture that removed the running memory entirely.
  • Attention — letting every position look directly at every other position.

Developer — Code and libraries.

Setup

bash
pip install numpy torch

The first example is NumPy only. The training examples use PyTorch on CPU with tiny inline data, and finish in a few seconds on any laptop.

The recurrence, with nothing hidden

One hidden number, two hand-picked weights, two words. Watch the memory change.

one_number_rnn.py
import numpy as np

# Two words, one number each. A toy stand-in for a real embedding.
VOCAB = {"not": -1.0, "good": 1.0}

Wx, Wh, b = 1.5, 0.9, 0.0      # fixed by hand, so your run prints exactly these numbers

def run(sentence):
    h = 0.0                                        # the memory starts empty
    print(f"  start            h = {h:+.4f}")
    for word in sentence.split():
        x = VOCAB[word]
        h = np.tanh(Wx * x + Wh * h + b)           # new memory = this word, plus what I remembered
        print(f"  after {word:<10} h = {h:+.4f}")
    return h

print("sentence: not good")
a = run("not good")
print()
print("sentence: good not")
c = run("good not")
print()
print("same two words, different order ->", round(a, 4), "vs", round(c, 4))
Output
sentence: not good
  start            h = +0.0000
  after not        h = -0.9051
  after good       h = +0.5950

sentence: good not
  start            h = +0.0000
  after good       h = +0.9051
  after not        h = -0.5950

same two words, different order -> 0.595 vs -0.595

Identical words, opposite final memory. That single line is the reason RNNs exist, and a model that averages word vectors cannot reproduce it.

np.tanh is the squashing function that keeps the memory inside the range -1 to 1. Without it, h would grow without limit over a long sequence and overflow.

A real one, trained on tiny data

Now nn.RNN, learning that "not" flips the meaning of what follows.

negation_rnn.py
import torch
import torch.nn as nn

torch.manual_seed(0)

WORDS = ["not", "very", "really", "good", "great", "bad", "awful"]
ID = {w: i for i, w in enumerate(WORDS)}

TRAIN = [
    ("good", 1), ("great", 1), ("bad", 0), ("awful", 0),
    ("very good", 1), ("very great", 1), ("very bad", 0), ("very awful", 0),
    ("really good", 1), ("really great", 1), ("really bad", 0), ("really awful", 0),
    ("not good", 0), ("not great", 0), ("not bad", 1), ("not awful", 1),
    ("not very good", 0), ("not very bad", 1), ("not very awful", 1),
    ("not really good", 0), ("not really great", 0), ("not really awful", 1),
]
HELD_OUT = "not really bad"

def encode(s):
    return torch.tensor([[ID[w] for w in s.split()]])     # shape (1, length)

class TinyRNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.embed = nn.Embedding(len(WORDS), 8)
        self.rnn = nn.RNN(8, 16, batch_first=True)
        self.out = nn.Linear(16, 1)
    def forward(self, ids):
        _, h_last = self.rnn(self.embed(ids))   # h_last is the memory after the final word
        return self.out(h_last[-1])             # one raw score: positive means "good"

model = TinyRNN()
opt = torch.optim.Adam(model.parameters(), lr=0.03)
lossfn = nn.BCEWithLogitsLoss()

for epoch in range(1, 301):
    total = 0.0
    for s, y in TRAIN:
        opt.zero_grad()
        loss = lossfn(model(encode(s)), torch.tensor([[float(y)]]))
        loss.backward()
        opt.step()
        total += loss.item()
    if epoch in (1, 50, 150, 300):
        print(f"epoch {epoch:3d}  average loss {total/len(TRAIN):.4f}")

print()
model.eval()
with torch.no_grad():
    for s in ["good", "not good", "very bad", "not very bad", HELD_OUT]:
        tag = "  <- never seen in training" if s == HELD_OUT else ""
        print(f"{s:<15} -> {torch.sigmoid(model(encode(s))).item():.2f}{tag}")
Output
epoch   1  average loss 0.8425
epoch  50  average loss 0.0033
epoch 150  average loss 0.0004
epoch 300  average loss 0.0001

good            -> 1.00
not good        -> 0.00
very bad        -> 0.00
not very bad    -> 1.00
not really bad  -> 1.00  <- never seen in training

The loss numbers may differ in the last decimal place on a different PyTorch version, because weight initialisation changes between releases. The pattern will not change.

The last line is the one that matters. not really bad was never in the training set, and the model still calls it positive. It picked up a rule about position, not a lookup table of phrases.

The parts worth reading twice

h_last versus the full output. nn.RNN returns two things. The first is every step's memory, shape (batch, length, hidden) — used when you need a prediction per word, such as tagging each token. The second is only the final memory, shape (layers, batch, hidden) — used when you need one prediction per sequence, as here.

batch_first=True. Without it, PyTorch expects (length, batch, features), with batch in the middle. That default surprises everyone. Set it once, at construction, and stay consistent.

h_last[-1]. The leading dimension counts layers, not time. Index -1 takes the top layer. With bidirectional=True this dimension doubles and you must concatenate the two directions yourself.

Batch size one. Sentences here have different lengths, so they are fed one at a time. Real code pads them to equal length and wraps them in nn.utils.rnn.pack_padded_sequence, which tells the RNN to stop at each sequence's true end instead of learning from padding.

Watching the memory fade

This is the vanishing gradient, measured. The weights are set by hand so the numbers are identical on every machine.

fading_memory.py
import torch
import torch.nn as nn

rnn = nn.RNN(input_size=1, hidden_size=1, batch_first=True)

# Set the weights by hand so this prints the same numbers on any machine.
with torch.no_grad():
    rnn.weight_ih_l0.fill_(1.0)      # how much the current input matters
    rnn.weight_hh_l0.fill_(0.5)      # how much the previous memory carries forward
    rnn.bias_ih_l0.zero_()
    rnn.bias_hh_l0.zero_()

for length in [5, 10, 20, 40]:
    x = torch.zeros(1, length, 1, requires_grad=True)
    out, _ = rnn(x)
    out[0, -1, 0].backward()         # how much does the FIRST input affect the LAST output?
    first = x.grad[0, 0, 0].item()
    last = x.grad[0, -1, 0].item()
    print(f"length {length:3d}   influence of step 1: {first:.3e}   of the final step: {last:.3e}")
Output
length   5   influence of step 1: 6.250e-02   of the final step: 1.000e+00
length  10   influence of step 1: 1.953e-03   of the final step: 1.000e+00
length  20   influence of step 1: 1.907e-06   of the final step: 1.000e+00
length  40   influence of step 1: 1.819e-12   of the final step: 1.000e+00

At 40 steps, the first input has about a millionth of a millionth of the influence of the last one. Training cannot correct a weight it receives no signal about. The first word is, for practical purposes, invisible.

The recurrent weight here is 0.5, so influence falls as 0.5 raised to the number of steps. Set it to 1.5 instead and the numbers explode rather than vanish, which produces inf and then nan in training. Both failures come from the same repeated multiplication.

Common mistakes

Forgetting batch_first=True. Your tensor is (batch, length, features) and the layer reads it as (length, batch, features). No error is raised if the two happen to be compatible sizes, and the model learns nothing. Print .shape before the layer.

Not detaching the hidden state between batches. Carrying h forward across batches without h = h.detach() keeps the graph from the previous batch alive. You get RuntimeError: Trying to backward through the graph a second time, and memory that grows until the process dies.

No gradient clipping. RNN gradients explode readily. Add torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) between loss.backward() and opt.step(). It is one line and it prevents a whole class of nan losses.

Training on padded positions. Padding tokens contribute loss and gradient unless you mask them or pack the batch. The model dutifully learns to predict padding.

Reaching for an RNN for long text. Above a few hundred tokens, use a transformer. RNNs earn their place on streams and on tight hardware budgets, not on long documents.

Try it yourself

Delete the four really lines from TRAIN and rerun. The held-out phrase should now fail, because the model has never seen really in a negated position. Shrinking the data until a model breaks teaches more than watching it succeed.

Then swap nn.RNN for nn.LSTM. The only change needed is that it returns (h, c) instead of h, so unpack two values. Compare how fast each reaches a low loss.

What to learn next

  • LSTM — the gated cell that made long-range memory workable.
  • Transformers — the architecture that removed the running memory entirely.
  • Attention — letting every position look directly at every other position.

Researcher — Mathematics and papers.

The Elman recurrence

Elman (1990), Finding structure in time, defines the standard form:

h_t = φ( W_hh · h_{t−1}  +  W_xh · x_t  +  b_h )
y_t = W_hy · h_t + b_y

x_t ∈ R^d is the input at step t, h_t ∈ R^m the hidden state, φ an elementwise non-linearity (tanh classically), and W_hh ∈ R^{m×m}, W_xh ∈ R^{m×d}, W_hy ∈ R^{k×m} the shared parameters. The Jordan variant feeds back y_{t−1} instead of h_{t−1}.

Parameter count is m² + md + m per layer and is independent of sequence length T. This weight sharing across time is the direct analogue of a CNN's weight sharing across space, and it is what makes variable-length input tractable.

Backpropagation through time, and why it fails

Unrolling for T steps gives a feedforward network of depth T with tied weights. Werbos (1990) formalised BPTT. The gradient of the loss at step T with respect to an early state factorises:

∂L_T / ∂h_t  =  ∂L_T / ∂h_T  ·  Π_{k=t+1..T}  ∂h_k / ∂h_{k−1}

∂h_k / ∂h_{k−1}  =  diag( φ'(a_k) ) · W_hh

a_k is the pre-activation at step k. The product of T − t Jacobians is the whole problem. Bounding its norm:

‖ ∂h_k / ∂h_{k−1} ‖  ≤  γ · ‖W_hh‖ ,      γ = sup |φ'|

γ = 1 for tanh and 0.25 for the logistic sigmoid. Bengio, Simard and Frasconi (1994) showed that if the largest singular value of W_hh satisfies σ_max < 1/γ, the product contracts geometrically and long-range gradients vanish. If the spectral radius exceeds 1/γ the product can diverge, and gradients explode.

The trap is that these are the same condition seen from two sides. Pascanu, Mikolov and Bengio (2013) state it directly: a vanilla RNN cannot both store information stably over long spans and receive usable long-range gradient, because stable storage requires contraction and gradient flow requires none.

Practical consequences, all standard:

  • Gradient clipping by global norm handles the explosion half. It does nothing for vanishing.
  • Truncated BPTT backpropagates only k steps, trading long-range credit assignment for bounded memory. Memory during BPTT is O(T · m) for stored activations, which is the real constraint on T.
  • Orthogonal or identity initialisation of W_hh places the spectrum on the unit circle at the start. Le, Jaitly and Hinton (2015) showed an identity-initialised ReLU RNN competing with LSTMs on long-dependency tasks.

Gated cells

LSTM (Hochreiter and Schmidhuber, 1997) introduces an additive cell state c_t:

c_t = f_t ⊙ c_{t−1}  +  i_t ⊙ g_t
h_t = o_t ⊙ tanh(c_t)

f_t, i_t, o_t ∈ (0,1)^m are the forget, input and output gates, g_t the candidate update, and ⊙ elementwise product. The gradient path through c_t is multiplication by f_t rather than by W_hh with a saturating derivative. With f_t ≈ 1 the path is close to the identity, so gradients survive far longer. Gers, Schmidhuber and Cummins (2000) added the forget gate; initialising its bias to 1.0 is a standard and genuinely useful trick.

GRU (Cho et al., 2014) merges the cell and hidden state and drops one gate, giving roughly 25% fewer parameters. Greff et al. (2017), LSTM: A Search Space Odyssey, ran a large ablation and found no variant reliably beating the standard LSTM, with the forget gate and output activation the components that matter most.

Why transformers displaced them

The decisive property is not accuracy, it is parallelism.

                     sequential steps   time per layer      inference state
RNN / LSTM           O(T)               O(T · m²)           O(m)
Transformer          O(1)               O(T² · d + T · d²)  O(T · d) KV cache

An RNN's step t cannot begin before step t−1 finishes, so training time scales with T regardless of available hardware. A transformer computes all positions at once, and Vaswani et al. (2017) explicitly cite the number of sequential operations as the design driver. Path length between any two positions is O(1) under attention and O(T) under recurrence, which also removes the vanishing-gradient argument entirely.

The RNN wins on inference state: constant memory per step against a KV cache growing linearly in context length. That is why streaming and on-device workloads never fully abandoned recurrence.

The linear-recurrence revival

Modern work recovers parallel training by dropping the non-linearity from the recurrence, leaving h_t = A h_{t−1} + B x_t, which is an associative scan and computable in O(log T) depth.

  • S4 (Gu, Goel and Ré, 2021) parameterises A with HiPPO structure for principled long-range memory; strong results on Long Range Arena.
  • Mamba (Gu and Dao, 2023) makes the state-space parameters input-dependent and supplies a hardware-aware selective scan, reaching transformer-quality language modelling with linear scaling in T.
  • RWKV (Peng et al., 2023) formulates a transformer-like model with an RNN inference mode, giving constant-memory decoding.

Whether these match transformers on tasks requiring precise recall of arbitrary earlier tokens remains contested; the compression into a fixed-size state is exactly the constraint attention avoids. Hybrid stacks that interleave recurrent layers with a small number of attention layers currently look like the pragmatic answer.

Papers

  • Elman, Finding Structure in Time, Cognitive Science, 1990.
  • Werbos, Backpropagation Through Time: What It Does and How to Do It, Proc. IEEE, 1990.
  • Bengio, Simard and Frasconi, Learning Long-Term Dependencies with Gradient Descent is Difficult, IEEE Trans. Neural Networks, 1994.
  • Hochreiter and Schmidhuber, Long Short-Term Memory, Neural Computation, 1997.
  • Cho et al., Learning Phrase Representations using RNN Encoder-Decoder, 2014 — arxiv.org/abs/1406.1078
  • Sutskever, Vinyals and Le, Sequence to Sequence Learning with Neural Networks, 2014 — arxiv.org/abs/1409.3215
  • Pascanu, Mikolov and Bengio, On the Difficulty of Training Recurrent Neural Networks, 2013 — arxiv.org/abs/1211.5063
  • Greff et al., LSTM: A Search Space Odyssey, 2017 — arxiv.org/abs/1503.04069
  • Gu, Goel and Ré, Efficiently Modeling Long Sequences with Structured State Spaces (S4), 2021 — arxiv.org/abs/2111.00396
  • Gu and Dao, Mamba: Linear-Time Sequence Modeling with Selective State Spaces, 2023 — arxiv.org/abs/2312.00752

What to learn next

  • LSTM — the gated cell that made long-range memory workable.
  • Transformers — the architecture that removed the running memory entirely.
  • Attention — letting every position look directly at every other position.

What to learn next

These follow on from what you just read.

  • Deep Learning

    LSTM

    An LSTM adds a separate memory line to a recurrent network, with taps that decide what to write, what to keep and what to read out, so information can survive hundreds of steps.

  • Deep Learning

    GANs

    A GAN trains two networks against each other, one inventing fakes and one catching them, so the invented data gets better without anyone ever writing down what good looks like.

  • Deep Learning

    Autoencoders

    An autoencoder squeezes its input through a narrow middle and rebuilds it, and the squeezed middle turns out to be a more useful description of the data than the input was.