Recurrent networks
Process sequences step by step, and their limits.
Create a free account Try part of it here
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
- A network with memory
- One step at a time, with small numbers
- A character-level language model
- Why RNNs forget
- LSTMs and GRUs
- 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.
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:
- is the step number: 1 for the first character, 2 for the second, and so on.
- is the input at step , as a list of numbers.
- is the new hidden state after step .
- is the hidden state from the step before.
- is a table of weights (a matrix) for the input.
- is a second weight matrix, for the old hidden state.
- is the bias, a list of numbers that gets added on.
- is a third weight matrix. It turns the hidden state into the step's output.
Bold letters are lists of numbers (vectors). The capital s are matrices. And tanh is the activation you met earlier. It squashes any number into the range −1 to 1.
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.
The same weights run at every step, so any length of text works. The memory is what fades over long distances.
One step at a time, with small numbers
Shrink everything to single numbers so you can follow it by hand. Use an input weight and a memory weight . 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))
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.
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))
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:
- Turn characters into numbers.
charslists every different character in the text.stoi("string to integer") gives each one a number. - Build the model.
nn.Embeddingturns each character's number into a list of 48 learned numbers.WxandWhare the two weight tables from the formula.outturns the hidden state into one score per character. These scores are called logits. A higher score means "more likely to come next". - 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.
- 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.)
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 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 , 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, is a matrix, so each factor is a matrix too. The one-number version is a simplification, but the effect is much the same.
g = 1.0 for step in range(10): g = g * 0.5 print(g)
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.
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.
- One step at a time. Step needs the result of step . 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.
- 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.)
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
Try a quiz question
What does an RNN's hidden state represent?