Index Ai Academy
Phase 4 · Deep learning · Architectures

Recurrent networks

Process sequences step by step, and their limits.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Language comes in order, one word after another. Before transformers, recurrent networks were how neural networks read and wrote it. Machine translation, speech recognition and early text generation all ran on them. Training a small one shows you the idea every language model shares: predict the next token from what came before. It also shows two weak spots, forgetting and working one step at a time. The transformer was built to fix both.

What you'll be able to do

  • Explain how a recurrent network carries a hidden state through a sequence
  • Train a character-level RNN language model and sample text from it
  • Show why gradients vanish across long sequences, and what LSTMs changed
  • Explain the limits that led to transformers

What it covers

  1. A network with memory
  2. One step at a time, with small numbers
  3. A character-level language model
  4. Why RNNs forget
  5. LSTMs and GRUs
  6. The limits that led to transformers

A network with memory

Read this sentence one word at a time. By the last word, you still know how it started. You don't hold every word in your head. You keep a running sense of what it's about, and you update it with each new word.

A recurrent neural network (RNN) works the same way. It reads a sequence one step at a time. A step might be one word, one character, or one day of sales. The network carries a list of numbers from step to step, called the hidden state. The hidden state is its running summary of everything it has read so far.

Each step is one small job. The network mixes the new input with the old hidden state. That gives the new hidden state. Then it moves on to the next step. It uses the same weights at every step. "Recurrent" means it keeps coming back to the same calculation.

Quick check
What does an RNN's hidden state hold?
  1. Only the current character
  2. The whole text, word for word
  3. A running summary of everything it has read so far
  4. The final answer
Show the answer

A running summary of everything it has read so far

It's a fixed-size memory, passed along at every step. That size limit is part of why long texts are hard for it.

Here is that step as a formula. It has several symbols, so take them one at a time:

  • tt is the step number: 1 for the first character, 2 for the second, and so on.
  • xt\mathbf{x}_t is the input at step tt, as a list of numbers.
  • ht\mathbf{h}_t is the new hidden state after step tt.
  • ht−1\mathbf{h}_{t-1} is the hidden state from the step before.
  • WxW_x is a table of weights (a matrix) for the input.
  • WhW_h is a second weight matrix, for the old hidden state.
  • b\mathbf{b} is the bias, a list of numbers that gets added on.
  • WoW_o is a third weight matrix. It turns the hidden state into the step's output.

Bold letters are lists of numbers (vectors). The capital WWs are matrices. And tanh is the activation you met earlier. It squashes any number into the range −1 to 1.

ht=tanh⁡(Wxxt+Whht−1+b)outputt=Woht\mathbf{h}_t = \tanh(W_x \mathbf{x}_t + W_h \mathbf{h}_{t-1} + \mathbf{b}) \qquad \text{output}_t = W_o \mathbf{h}_t

In plain words: new memory = tanh(weights × new input + weights × old memory + bias). The output at each step is read off the new memory. The same weights are used at every step.

Now picture the network drawn out with one copy per step. This is called unrolling it. Unrolled, it looks like a very deep network, with one layer per step. But all those layers share one set of weights. That's why an RNN can read a sequence of any length. It applies the same cell again for each new step.

Try it now

The same weights run at every step, so any length of text works. The memory is what fades over long distances.

Worked example

One step at a time, with small numbers

Shrink everything to single numbers so you can follow it by hand. Use an input weight wx=0.5w_x = 0.5 and a memory weight wh=0.8w_h = 0.8. Leave out the bias. The hidden state starts at 0. Feed in one signal (an input of 1), then three quiet steps (inputs of 0).

  • Step 1: 0.5 × 1 + 0.8 × 0 = 0.5. And tanh(0.5) is about 0.4621.
  • Step 2: 0.5 × 0 + 0.8 × 0.4621 is about 0.3697. Its tanh is about 0.3537.
  • Step 3: 0.8 × 0.3537 is about 0.2830. Its tanh is about 0.2757.

The input went quiet after step 1, but the hidden state still remembers it. Here is the same calculation in Python. The for loop is the "recurrent" part.

import math

w_x, w_h, b = 0.5, 0.8, 0.0     # one weight for the input, one for the memory
h = 0.0                          # the hidden state starts empty
for x in [1, 0, 0, 0]:           # one signal, then three quiet steps
    h = math.tanh(w_x * x + w_h * h + b)
    print(x, round(h, 4))
It runs right here in your browser, with nothing to install.

Each line shows the input and the new hidden state: 0.4621, then 0.3537, 0.2757 and 0.217. The memory of that first input fades at every step. Each step multiplies the old memory by 0.8, and tanh shrinks it a little more. Keep this picture in mind. It's the root of the forgetting problem later in this lesson.

Quick check
Run the same loop with the memory weight w_h set to 0. What does the second line print?
import math

w_x, w_h, b = 0.5, 0.0, 0.0
h = 0.0
for x in [1, 0, 0, 0]:
    h = math.tanh(w_x * x + w_h * h + b)
    print(x, round(h, 4))
  1. 0 0.0
  2. 1 0.4621
  3. 0 0.4621
  4. 0 0.3537
Show the answer

0 0.0

With w_h = 0, the old hidden state is multiplied by 0 and drops out. The input at step 2 is 0, and tanh(0) = 0. The network has no memory at all.

A character-level language model

Now a real one. You'll train an RNN to predict the next character of a short text. Then you'll let it write. This is a language model in the same sense as an LLM, only tiny. It works on characters instead of tokens. And it learns from a few hundred characters instead of trillions of tokens.

The training text is a few short lines about outdoor gear, repeated four times. Here is what the code does, in order:

  1. Turn characters into numbers. chars lists every different character in the text. stoi ("string to integer") gives each one a number.
  2. Build the model. nn.Embedding turns each character's number into a list of 48 learned numbers. Wx and Wh are the two weight tables from the formula. out turns the hidden state into one score per character. These scores are called logits. A higher score means "more likely to come next".
  3. Train it. Each of the 300 steps picks 16 random snippets of 20 characters. The targets are the same snippets moved along by one character. So at every position, the right answer is the next character. The loss is cross-entropy, from Phase 2.
  4. Let it write. Start from the text "good ". Turn the scores for the next character into chances, pick one at random, and add it to the text. Then feed it back in. Repeat 80 times.
import torch
import torch.nn as nn

text = ("great gear for great trails. light layers for long days. "
        "good boots for wet trails. warm layers for cold nights. ") * 4
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
data = torch.tensor([stoi[c] for c in text])
V = len(chars)

class CharRNN(nn.Module):
    def __init__(self, vocab, hidden):
        super().__init__()
        self.hidden = hidden
        self.embed = nn.Embedding(vocab, hidden)          # each character → a vector
        self.Wx = nn.Linear(hidden, hidden)
        self.Wh = nn.Linear(hidden, hidden, bias=False)
        self.out = nn.Linear(hidden, vocab)               # hidden state → next-character logits

    def forward(self, idx, h=None):                       # idx: (batch, time)
        h = torch.zeros(idx.shape[0], self.hidden) if h is None else h
        x = self.embed(idx)
        logits = []
        for t in range(idx.shape[1]):                     # one step at a time, same weights
            h = torch.tanh(self.Wx(x[:, t]) + self.Wh(h))
            logits.append(self.out(h))
        return torch.stack(logits, dim=1), h

torch.manual_seed(0)
model = CharRNN(V, 48)
opt = torch.optim.Adam(model.parameters(), lr=0.01)
T = 20
for step in range(300):
    starts = torch.randint(0, len(data) - T - 1, (16,)).tolist()
    xb = torch.stack([data[i:i + T] for i in starts])           # 16 snippets of 20 characters
    yb = torch.stack([data[i + 1:i + T + 1] for i in starts])   # the same snippets shifted by one
    logits, _ = model(xb)
    loss = nn.functional.cross_entropy(logits.view(-1, V), yb.view(-1))
    opt.zero_grad()
    loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step()
    if step % 100 == 0 or step == 299:
        print(f"step {step:>3}  loss {loss.item():.3f}")

with torch.no_grad():
    out = "good "
    logits, h = model(torch.tensor([[stoi[c] for c in out]]))
    for _ in range(80):
        probs = torch.softmax(logits[:, -1] / 0.7, dim=-1)       # temperature 0.7
        nxt = torch.multinomial(probs, 1)
        out += chars[nxt.item()]
        logits, h = model(nxt, h)                                # feed the sample back in
print(repr(out))

Everything you met in Phase 1 is here in miniature. Next-token prediction is the training target. Cross-entropy is the loss. Writing works by sampling with a temperature and feeding each output back in. On this little text the model mostly memorizes. Sometimes it mixes phrases it saw into new ones.

Two details are worth a note. Dividing the scores by 0.7 sets the temperature. A value below 1 makes likely characters even more likely, so the writing is less random. And clip_grad_norm_ caps how big the gradient can get at each step. RNNs are prone to exploding gradients, and this stops one bad step from wrecking training. (torch here is the course's in-browser PyTorch subset.)

Quick check
Imagine shorter snippets: suppose one input snippet in xb were the five characters "boots". In the text, "boots" is followed by a space. What would the matching target snippet in yb be?
  1. "good ": the start of the line
  2. "boots": the same characters
  3. "oots ": each character's next character
  4. "boot": one character shorter
Show the answer

"oots ": each character's next character

The target is the input moved along by one place. Under each character sits the one that came next: b→o, o→o, o→t, t→s, and s→space.

Why RNNs forget

In the worked example, a memory faded a little at every step. Training has the same problem, and it's worse.

Training uses backpropagation. Through an unrolled RNN, the gradient travels backward one time step at a time. At each step it gets multiplied by one factor, the same way it does through the layers of a deep network. The difference is that it's the same WhW_h every time. If those factors are below 1, the gradient shrinks step after step. You met this in the lesson on vanishing gradients.

The code below measures it. It runs a fresh, untrained RNN over a 40-character sequence. The loss depends only on the last step. Then it checks how much gradient reaches the hidden state at earlier steps. retain_grad() asks PyTorch to keep those in-between gradients so you can look at them. .norm() gives the size of each gradient as one number.

import torch
import torch.nn as nn

torch.manual_seed(1)
H = 48
embed, Wx, Wh, out = nn.Embedding(20, H), nn.Linear(H, H), nn.Linear(H, H, bias=False), nn.Linear(H, 20)
x = embed(torch.randint(0, 20, (1, 40))).detach()
h, states = torch.zeros(1, H), []
for t in range(40):
    h = torch.tanh(Wx(x[:, t]) + Wh(h))
    h.retain_grad()                         # keep the gradient of this intermediate value
    states.append(h)
out(h).sum().backward()                     # a loss that depends only on the last step
for t in [39, 35, 30, 20, 10, 0]:
    print(f"gradient reaching step {t:>2}: {states[t].grad.norm().item():.1e}")

The numbers print in short form. A value like 2.5e-04 means 2.5 with the decimal point moved 4 places to the left: 0.00025.

The signal shrinks by roughly 10× every few steps. By the start of the sequence, it's close to zero. So the network can't learn that something at step 0 matters for step 39. A plain RNN has a short memory, in training as much as in use.

Go deeper Where each backward factor comes from

Go back to the one-number RNN from the worked example. Going backward through one step, the gradient gets multiplied by two things.

  • The memory weight whw_h, which was 0.8.
  • The slope of tanh at that step, which is never more than 1.

So each step multiplies the gradient by 0.8 or less. Over 40 steps, that's at most 0.8 multiplied by itself 40 times. In Python, 0.8 ** 40 is about 0.00013. A memory weight above 1 can push the other way and make gradients explode.

In a real RNN, WhW_h is a matrix, so each factor is a matrix too. The one-number version is a simplification, but the effect is much the same.

Quick check
A gradient is multiplied by 0.5 at each of 10 backward steps. Predict what this prints.
g = 1.0
for step in range(10):
    g = g * 0.5
print(g)
  1. 5.0
  2. 0.5
  3. 0.0009765625
  4. 0.05
Show the answer

0.0009765625

0.5 multiplied by itself 10 times is exactly 0.0009765625, about a thousand times smaller. Over 40 steps it would be about a trillion times smaller.

LSTMs and GRUs

The LSTM (long short-term memory, 1997) was the fix. Alongside the hidden state, it keeps a second memory called the cell state. Think of it as a notebook. A plain RNN rewrites its whole memory at every step, through tanh. An LSTM mostly adds new notes to its notebook and crosses a few old ones out.

Small learned switches called gates decide what happens at each step. Each gate gives numbers between 0 and 1. A 0 means "block" and a 1 means "let through".

  • The forget gate decides what to cross out.
  • The input gate decides what new notes to write.
  • The output gate decides what to read out as the hidden state.

The cell state is updated mostly by addition. So gradients can flow back along it without being squashed at every step. It works like a residual connection through time: a shortcut path the signal can travel along mostly unchanged. As a result, LSTMs can learn from much longer sequences. The GRU (gated recurrent unit) is a simpler variant with fewer gates.

In PyTorch they're one line each:

rnn = nn.LSTM(input_size=64, hidden_size=128, num_layers=2, batch_first=True)
outputs, (h_n, c_n) = rnn(x)            # x: (batch, time, 64) → outputs: (batch, time, 128)

Here x is a batch of sequences, with 64 numbers per step. hidden_size=128 sets the size of the hidden state. num_layers=2 stacks two LSTMs, the second reading the first one's outputs. Besides the outputs, you get back the last hidden state h_n and the last cell state c_n.

LSTMs drove the big advances in translation and speech around 2014–2017.

Quick check
Why can gradients travel further through an LSTM's cell state?
  1. It's updated mostly by addition, so they aren't squashed at every step
  2. It has more layers
  3. It reads the text backward
  4. It uses a larger learning rate
Show the answer

It's updated mostly by addition, so they aren't squashed at every step

A plain RNN multiplies the gradient at every step. Addition gives it a clearer path back.

Go deeper Why a forget gate keeps gradients alive

Along the cell state, each backward step multiplies the gradient by the forget gate's value. That value is the share of the old notes the LSTM keeps.

Suppose the forget gate keeps 95% at every step. After 40 steps, 0.95 multiplied by itself 40 times is about 0.13. That's still a signal the network can learn from. Now compare a plain RNN where each step multiplies by 0.5. After 40 steps, about one trillionth is left.

Real LSTMs have other paths for the gradient too, so this is a simplification. But it's the main reason they remember for longer.

The limits that led to transformers

Even LSTMs kept two problems.

  1. One step at a time. Step tt needs the result of step t−1t-1. So a long sequence can't be processed in parallel. Think of a long queue at a single checkout. GPUs are built to do thousands of sums at once, so during training they sit underused.
  2. A bottleneck. Everything about the past must squeeze into one fixed-size hidden vector. It's like summing up a whole book on one index card. And distant information still fades.

The transformer (2017) dropped recurrence. It uses attention: every position looks directly at every earlier position, all in one parallel step. There's no long chain of multiplications in between. That's Phase 5. (Some recent "state-space" models bring recurrence back, with clever ways around both limits. So the idea isn't gone.)

Quick check
Why are RNNs slow to train on long sequences compared with transformers?
  1. RNNs need a separate model per time step
  2. RNNs can't use GPUs
  3. Each time step depends on the previous one, so the steps can't be computed in parallel
  4. RNNs have more parameters
Show the answer

Each time step depends on the previous one, so the steps can't be computed in parallel

The hidden state forms a chain, so each step waits for the one before. Attention lets all positions be computed at once.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What does an RNN's hidden state represent?

  1. A running summary of the sequence so far, updated at every step
  2. The final answer, which it keeps refining at each step
  3. The weights of the network, as they change at each step
  4. The next input, which it predicts ahead of time at each step

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson