Ch. 13
Sequences & Time Series
RNN, LSTM, GRU, why vanishing-gradient is structural, when to use RNNs in 2026 (rarely).
Karpathy trained a character-level LSTM on the Linux kernel source and it started generating plausible C, complete with matched braces, indentation, and static inline keywords used in roughly the right places. The model had never been told what a brace was. It had never been told what a function declaration looked like. It had been told one thing, repeated a few hundred million times: predict the next character. Out of that single signal it built a representation rich enough that opening braces and closing braces participated in the same internal state. That is the magic of recurrent networks, and that is also their limit. The same architecture cannot reliably remember a fact from five thousand tokens ago. By the end of this chapter you will have built a vanilla RNN, an LSTM cell, and a 1D-convolutional sequence model from scratch, and you will know exactly which architectural choices were forced by the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → flow and which were forced by the data.
FIG 13.1 · Learning outcomes
By the end of this chapter you will be able to:
- Implement a vanilla RNN cell in 15 lines of NumPy, including the forward pass and one step of backprop through time.
- State the vanishing-gradient argument for RNNs as a product of Jacobians, and explain why LSTM cell state side-steps it.
- Build an LSTM cell from scratch (input, forget, output gates plus candidate cell state) in under 50 lines, with weight initialisation that actually trains.
- Distinguish GRU from LSTM in one paragraph, and pick one for a small project.
- Train a seq2seq encoder-decoder on a tiny translation dataset, with greedy decoding, in a single Colab notebook.
- Build a dilated 1D CNN (WaveNet-style) for sequence modeling and explain why it can have a multi-thousand-step receptive field without being recurrent.
- Forecast a time series with both a classical baseline and a small RNN, and tell when the baseline is good enough.
- Say "in 2026, I should reach for an RNN when ____" and finish the sentence with three concrete cases.
FIG 13.2 · What you need first
- Ch 9 — Neural Networks Introduction — forward pass, activations, backprop. You need to be comfortable composing layers.
- Ch 10 — PyTorch —
nn.Module,DataLoader, optimizer step. Every code block uses them. - Ch 11 — Training Deep Neural Networks — gradient clipping in particular. RNN training without clipping is unstable; we will use it from section 3 onward.
If you skipped another chapter (CNNs): mostly fine. We touanother chapterD convolutions in section 11, and the receptive-field arithmetic transfers. The conv arithmetic is re-derived here.
FIG 13.3.1
Why sequences need a different architecture
A standard MLP takes a fixed-size input and produces a fixed-size output. That is fatal when your input is a sentence, a stock price history, an audio waveform, or a video. These have variable length, and the order of the tokens matters. You can pad to a fixed length and feed it through an MLP, but you will pay the full quadratic-in-length One of the model's internal numbers that gets adjusted as it learns.Full glossary → cost every time, and the model has no A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary → that says "the same operation applied at every position".
Karpathy's framing in 24-founder-blogs/karpathy-rnn-effectiveness §sequences lists five sequence regimes worth distinguishing: one-to-many (image captioning), many-to-one (sentiment classification), many-to-many synchronised (frame-by-frame video tagging), many-to-many shifted (translation), and many-to-many same-length (POS tagging). All of them are awkward for an MLP. All of them are natural for a A model that reads a sequence one piece at a time while keeping a 'note to self' about everything it has seen so far.Full glossary →.
A recurrent network does one thing differently: it maintains a A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary → that is updated at every position, using the same weights at every position. Formally, given a sequence :
where is a learned function. The output at position is some function of . The same parameters are shared across all positions. This is the recurrent analogue of the convolutional A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary →-sharing trick: the architecture exploits the fact that the same operation applies at every step. Karpathy's one-liner is worth memorising: "if training vanilla neural nets is optimization over functions, training recurrent nets is optimization over programs". An RNN is closer to a tiny CPU with one fixed instruction.
FIG 13.3.2
The vanilla RNN
The simplest recurrent cell uses a single linear layer on the concatenated previous A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary → and current input, followed by a tanh:
The output, if the task is many-to-many synchronised:
That is the whole equation. Three A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → matrices, two A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → vectors, one nonlinearity. Karpathy's blog has this in 4 lines of NumPy.
nn.RNN vs. a from-scratch vanilla RNN (tanh recurrence)
DL primitivernn = nn.RNN(input_size=64, hidden_size=128, num_layers=1, batch_first=True, nonlinearity='tanh')
output, h_final = rnn(x) # x: (32, 50, 64)
# output: (32, 50, 128); h_final: (1, 32, 128). Add a separate nn.Linear(128, vocab) for the y head.class VanillaRNN:
def __init__(self, vocab_size, hidden_size):
self.W_hh = np.random.randn(hidden_size, hidden_size) * 0.01
self.W_xh = np.random.randn(hidden_size, vocab_size) * 0.01
self.W_hy = np.random.randn(vocab_size, hidden_size) * 0.01
self.b_h = np.zeros((hidden_size, 1))
self.b_y = np.zeros((vocab_size, 1))
def forward(self, x_seq):
h = np.zeros((self.W_hh.shape[0], 1))
hs, ys = [], []
for x in x_seq:
h = np.tanh(self.W_hh @ h + self.W_xh @ x + self.b_h)
y = self.W_hy @ h + self.b_y
hs.append(h); ys.append(y)
return hs, ysfrom scratch: draft.md §2 (The vanilla RNN): VanillaRNN (NumPy) — not present in solution.py
- 1
nn.RNN(input_size, hidden_size, nonlinearity='tanh') weights weight_ih/weight_hhW_xh, W_hh and the line h = tanh(W_hh @ h + W_xh @ x + b_h) - 2
output (the (B, T, H) returned tensor)hs — the list of hidden states collected at every timestep - 3
h_final (the (1, B, H) last state)the final h after the for-loop over x_seq - 4
the implicit zero initial hidden stateh = np.zeros((hidden_size, 1)) before the loop - 5
the fused over-time recurrence inside rnn(x)the explicit `for x in x_seq:` Python loop
What the one call hides
- nn.RNN does NOT include the output projection W_hy/b_y — that y = W_hy @ h + b_y step is a separate nn.Linear you must add; the scratch class bundles it in.
- PyTorch inits weights from U(-1/sqrt(H), 1/sqrt(H)), not the small fixed 0.01 scale; the draft notes the default can make recurrent dynamics blow up on early passes — a real divergence the one-liner hides.
- nn.RNN uses separate weight_ih/weight_hh plus two biases (b_ih + b_hh); the scratch has one bias b_h — the redundant second bias is invisible (you set b_hh=0 to match).
- Batching across B, multi-layer stacking, and bidirectional are all available from the same constructor and run fused in C++/cuDNN.
- Gotcha: nonlinearity defaults to 'tanh' but can be set to 'relu', which changes the dynamics and the vanishing/exploding behaviour entirely.
- Gotcha: No gradient clipping is built in — a raw nn.RNN on long sequences will explode; you must add clip_grad_norm_ yourself (the chapter's §3 point).
- Gotcha: batch_first=False default means it expects (T, B, H); silent time/batch axis confusion as with nn.LSTM.
- Gotcha: The 1/sqrt(H) default init is exactly the case the draft warns diverges for W_hh; beginners blame their code, not the init scale.
Simple RNNs remain useful for small stateful sequence problems, but LSTMs, GRUs, and transformers are often stronger defaults. The four-line scratch version makes the shared recurrent update and its vanishing/exploding-gradient behavior visible.
On the job: At work you essentially never write a vanilla RNN by hand; you call nn.LSTM/nn.GRU and the only hand-written analogue is the training-loop gradient clipping the bare cell forces you to add.
That is the whole API. nn.RNN with nonlinearity='tanh' is the equation above, vectorised across A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → and time.
FIG 13.3.3
Backprop through time
To train an RNN you run it forward over the entire sequence, accumulate the loss at every timestep, and then run A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → flow backwards through the same shared weights at every timestep. This is called A calculation that works backward from the mistake to figure out how much each weight and bias was to blame for it.Full glossary → through time (BPTT). The unrolling is conceptual; in code it is the same backprop you already know, but with loss.backward traversing time steps before reaching the parameters.
The gradient of the loss at position with respect to the A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary → at position involves a product of Jacobians:
Each Jacobian factor has the form . The product is therefore governed by the spectral norm of these factors — the largest factor by which the matrix can stretch a vector, equal to its largest singular value (for alone, the relevant quantity is its largest-magnitude eigenvalue, the spectral radius). If that norm is below 1, the product shrinks exponentially toward zero as the gap grows; if above 1, it explodes. This is the matrix version of d2l's observation that repeated powers of have "eigenvalues smaller than 1 vanish and eigenvalues larger than 1 diverge". Both are bad. The vanishing case means the network cannot learn dependencies more than ≈20 steps apart. The exploding case means training diverges.
Practical fixes:
- Putting a cap on how big a single training adjustment can be so one wild step doesn't wreck progress.Full glossary →. Cap the global gradient norm at some threshold (typically 1.0 or 5.0). This stops explosions cold. Pascanu et al. 2013 popularised it.
- Truncated BPTT. Instead of unrolling over the entire sequence, unroll over a fixed window (say 35 steps) and detach the hidden state between windows. This is what every production RNN does.
- Smarter cells (LSTM, GRU). The structural fix for vanishing gradients, covered next.
# Truncated BPTT in PyTorch
hidden = None
for chunk in chunks_of_sequence:
if hidden is not None:
hidden = hidden.detach() # cut the gradient graph
output, hidden = rnn(chunk, hidden)
loss = criterion(output, target_chunk)
loss.backward()
torch.nn.utils.clip_grad_norm_(rnn.parameters(), max_norm=1.0)
optimizer.step()
optimizer.zero_grad()FIG 13.3.4
Why vanishing gradients are structural
The vanishing-A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → problem in RNNs is not a A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary → issue. It is a structural property of the architecture. The A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary → at position has to flow through a at every position from 1 to . The derivative of is bounded by 1 (and is much smaller almost everywhere). The product of such terms, multiplied by powers of , shrinks fast.
Olah's 24-founder-blogs/olah-2015-08-understanding-lstms §long-term-dependencies has the cleanest informal statement: "The clouds are in the sky" can be predicted from local context, but "I grew up in France. I speak fluent French" requires the model to remember "France" across an unbounded number of intervening tokens. RNNs in theory can; in practice they cannot.
The LSTM and GRU fix this by introducing a separate cell-state pathway that has linear (additive) updates instead of multiplicative tanh squashes. Gradients can flow through the cell state without being attenuated at every step. We will derive this next.
FIG 13.3.5
LSTM cell: gates from first principles
The LSTM (Hochreiter and Schmidhuber 1997) keeps two state vectors at every timestep: a cell state and a A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary → . The cell state is the long-term memory; the hidden state is the working memory and the output.
Four gates, each a A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary →-activated linear function of :
- (forget gate): which cell-state components to keep
- (input gate): which new candidate values to add
- (candidate values): the tanh-squashed proposal
- (output gate): which cell-state components to expose as the hidden state
The update:
The key line is the cell-state update: . This is an additive update on the cell state, gated multiplicatively. If and , then and information from many timesteps ago flows forward untouched. Gradients flow backward through this addition without the tanh-derivative attenuation. That is the entire trick.
nn.LSTM vs. a from-scratch LSTM cell
DL primitivelstm = nn.LSTM(input_size=embed, hidden_size=hidden, batch_first=True)
output, (h_n, c_n) = lstm(x) # x: (B, T, embed)
# output: (B, T, hidden); h_n, c_n: (1, B, hidden)
# single-step equivalent: cell = nn.LSTMCell(embed, hidden); h, c = cell(x_t, (h, c))class LSTMCell(nn.Module):
def __init__(self, input_size, hidden_size):
super().__init__()
self.gates = nn.Linear(input_size + hidden_size, 4 * hidden_size)
with torch.no_grad():
self.gates.bias.zero_()
self.gates.bias[:hidden_size].fill_(1.0) # forget-gate bias = 1
self.hidden_size = hidden_size
def forward(self, x, state):
h, c = state
combined = torch.cat([h, x], dim=-1)
proj = self.gates(combined)
f, i, g, o = proj.split(self.hidden_size, dim=-1)
f = torch.sigmoid(f); i = torch.sigmoid(i)
g = torch.tanh(g); o = torch.sigmoid(o)
c_new = f * c + i * g
h_new = o * torch.tanh(c_new)
return h_new, c_newfrom scratch: lab/solution.py: LSTMCell
- 1
nn.LSTM(input_size, hidden_size) weight matrices weight_ih/weight_hhnn.Linear(input_size + hidden_size, 4 * hidden_size) — one projection of cat([h, x]) producing all four gate pre-activations - 2
the input/forget/candidate/output gates computed internally by cuDNNproj.split(hidden, -1) -> f, i, g, o, then sigmoid(f), sigmoid(i), tanh(g), sigmoid(o) - 3
the cell-state recurrence folded inside lstm(x)c_new = f * c + i * g — the additive long-term-memory update - 4
the hidden-state / output the layer returnsh_new = o * torch.tanh(c_new) - 5
the over-time for-loop hidden inside lstm(x) (returns output: (B,T,H))CharLSTM.forward unrolls the cell with `for t in range(T): h, c = self.cell(x[:, t], (h, c))` - 6
h_n, c_n returned as the last-timestep statethe final (h, c) values left after the manual time loop
What the one call hides
- PyTorch keeps two separate weight matrices (weight_ih, weight_hh) and adds two biases (b_ih + b_hh), instead of the scratch version's single Linear over cat([h, x]) with one bias — algebraically equivalent, but the parameter layout and the redundant second bias differ.
- nn.LSTM's internal gate chunk order is i, f, g, o (input, forget, cell, output) — NOT the f, i, g, o order in this scratch code; the math is identical but the weight slices live in a different order (you must remap chunks to tie weights).
- nn.LSTM does NOT initialise the forget-gate bias to 1.0 (it inits everything from U(-1/sqrt(H), 1/sqrt(H))); the scratch version manually applies the Jozefowicz forget-bias-1 trick, which the one-liner silently omits.
- The whole time unroll, first-step hidden/cell zeroing, and batching across (B, T) are fused into one cuDNN-accelerated call — the scratch version exposes the explicit Python loop.
- nn.LSTM stacks layers (num_layers), goes bidirectional, and applies inter-layer dropout from the same constructor; the scratch cell is one unidirectional layer only.
- Gotcha: batch_first=False is the default — without it nn.LSTM expects (T, B, H), and feeding (B, T, H) silently swaps your time and batch axes.
- Gotcha: Default forget-gate bias is 0, so an untrained nn.LSTM starts out forgetting roughly half its memory each step; on hard long-range tasks you reach into the bias yourself for the from-scratch behaviour.
- Gotcha: output[:, -1] (last timestep of all-steps tensor) vs h_n[-1] (returned last state) diverge the moment you go bidirectional or multi-layer.
- Gotcha: h_n/c_n shape is (num_layers * num_directions, B, H), not (B, H); indexing assumptions break once you add a layer or a direction.
Prefer nn.LSTM in production (cuDNN-fused, batched, multi-layer, far faster); the scratch cell exists to prove .forward() is just one Linear over cat([h, x]) split into four gates with an additive cell-state update, and to let you do what nn.LSTM won't out of the box — e.g. the forget-bias-1 init or a custom gate.
On the job: At work you instantiate nn.LSTM and only ever subclass an LSTM cell when you need a non-standard gate, a custom recurrent dropout mask, or to surgically set the forget-gate bias.
FIG 13.3.6
GRU: the simplification that mostly works
The GRU (Cho et al. 2014) is the LSTM with one fewer gate. It merges the cell state and A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary → into a single state vector, and uses only two gates: an update gate (how much of the new candidate to mix in) and a reset gate (how much of the previous state to use when computing the candidate).
GRUs have about 75% of the parameters of an equivalent LSTM (three A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → matrices versus four). On most sequence tasks the The share of guesses the model got right out of all its guesses.Full glossary → difference is within noise. The original GRU paper and subsequent comparisons (16-d2l-sections/chapter_recurrent-modern__gru §comparison) found GRU faster to train and marginally weaker on the hardest long-range tasks. Use whichever your library defaults to; do not spend cycles choosing between them.
Library path:
gru = nn.GRU(input_size=64, hidden_size=128, batch_first=True)
x = torch.randn(32, 50, 64)
output, h_n = gru(x)
# Same API as nn.RNN, fewer parameters than nn.LSTMFIG 13.3.7
Bidirectional RNNs
For tasks where you have the full sequence available at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → time (POS tagging, named-entity recognition, sequence classification), running an RNN in both directions and concatenating the hidden states gives you context from both sides of each position.
bilstm = nn.LSTM(input_size=64, hidden_size=128, bidirectional=True, batch_first=True)
x = torch.randn(32, 50, 64)
output, _ = bilstm(x)
# output: (32, 50, 256) — forward 128 + backward 128 concatenatedReading a sequence both forward and backward so each spot has context from both sides.Full glossary → only works for non-causal tasks. You cannot use it for Generating text one piece at a time, where each new piece is chosen based on everything written so far.Full glossary → language modeling, where position should not see position . For tagging, classification, or any task where the entire input is available, bidirectional is strictly better than unidirectional.
FIG 13.3.8
Stacked RNNs
You can stack RNN layers the way you stack MLP layers. Layer 1's output sequence becomes layer 2's input sequence. Each layer has its own A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary →. Stacking gives the model more representational capacity per timestep.
deep_lstm = nn.LSTM(input_size=64, hidden_size=128, num_layers=3, batch_first=True, dropout=0.3)
x = torch.randn(32, 50, 64)
output, (h_n, c_n) = deep_lstm(x)
# h_n, c_n: (3, 32, 128) — one (h, c) pair per layerIn practice, 2 to 3 layers is the standard. Beyond that, the marginal gain shrinks and training stability decreases. Note the dropout argument: PyTorch's nn.LSTM applies A training trick where the model randomly switches off some of its own pieces each pass, so it can't lean too hard on any one of them.Full glossary → between layers (not within a layer's recurrent step). Recurrent dropout (within a step, applied with the same mask across timesteps) requires manual implementation or third-party libraries.
FIG 13.3.9
Sequence-to-sequence: encoder-decoder
For tasks where the input and output sequences have different lengths and you need to produce the output one A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → at a time (translation, summarisation, dialog), the standard pre-transformer architecture was the A two-part design where one half squeezes the input into a compact summary and the other half expands it into the output.Full glossary → RNN (Sutskever et al. 2014, Cho et al. 2014).
The encoder is an RNN that reads the input sequence and compresses it into a single A single bundle of numbers meant to summarize a whole input sequence.Full glossary → (typically the final A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary →). The decoder is another RNN that takes that context vector as its initial hidden state and generates the output sequence one token at a time, conditioned on its own previous outputs.
class EncoderDecoder(nn.Module):
def __init__(self, src_vocab: int, tgt_vocab: int, d: int = 256):
super().__init__()
self.src_emb = nn.Embedding(src_vocab, d)
self.tgt_emb = nn.Embedding(tgt_vocab, d)
self.encoder = nn.GRU(d, d, batch_first=True)
self.decoder = nn.GRU(d, d, batch_first=True)
self.head = nn.Linear(d, tgt_vocab)
def forward(self, src_ids, tgt_ids):
_, h_enc = self.encoder(self.src_emb(src_ids))
out, _ = self.decoder(self.tgt_emb(tgt_ids), h_enc)
return self.head(out)Here nn.Embedding(vocab, d) is a learned lookup table — a A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → matrix whose -th row is the -dimensional One piece of information about an example that the model looks at when making a guess.Full glossary → vector for token index , fetched directly by index and trained with the rest of the model. (This is the token-A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → sense, distinct from the t-SNE/UMAP "embedding" of another chapter.) A token is one discrete unit of the sequence — a character or word — and the The fixed set of all chunks a model is allowed to read or produce.Full glossary → is the fixed set of tokens the model can read or emit.
At training time, the decoder is fed the ground-truth previous tokens (During training, feeding a sequence model the correct previous answer instead of its own guess so it learns faster.Full glossary →); at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → it consumes its own previous predictions instead, which is where errors compound. Generating one token at a time by feeding each prediction back in is Generating text one piece at a time, where each new piece is chosen based on everything written so far.Full glossary → decoding, and taking the single highest-A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → token at every step (the strategy used in this chapter) is greedy decoding.
This architecture is the direct ancestor of the transformer, which kept the encoder-decoder framing and replaced the recurrent computation with A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →. another chapter develops the attention layer that sits on top of this. The A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary → you should anticipate: a single fixed-size context vector cannot carry all the information from a 50-word input sentence. Attention solves it.
The widget below is a preview of the idea another chapter formalises, not a detour. Here is the one-sentence version: instead of forcing the decoder to read from one frozen context vector, attention lets it build a fresh context at every output step, — a weighted sum of all the encoder hidden states, where the weights are a A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → over learned alignment scores between the current decoder state and each source position. The decoder, in effect, re-reads the source and focuses on the part it needs for the token it is about to emit. Watch the alignment weights light up as you step through a translation; that is the whole mechanism, and another chapter only adds the algebra.
FIG 13.3.10
1D convolutions for sequences
A 1D A small grid of numbers that slides across an image, at each spot multiplying its numbers by the patch underneath and adding them into one number.Full glossary → is to sequences what a 2D convolution is to images. A A small grid of weights that slides across an image to spot a particular pattern.Full glossary → of size slides along the sequence axis and produces a new sequence. Same arithmetic for output shape: .
# Input: (batch, channels, length). Output: (batch, channels_out, length_out)
conv1d = nn.Conv1d(in_channels=64, out_channels=128, kernel_size=3, padding=1)
x = torch.randn(32, 64, 100)
out = conv1d(x)
print(out.shape) # torch.Size([32, 128, 100])For sequences, 1D convs have two important properties RNNs do not:
- Parallelism. Every timestep is computed independently in a single matmul. RNNs serialise across time; convs do not.
- Fixed The patch of the original image that a single deep unit is actually looking at.Full glossary →. A stack of 1D convs has a finite, calculable receptive field. RNNs have a theoretically infinite (in practice limited) memory.
The trick that made 1D convs competitive on sequence modeling is dilated convolution. A dilated conv with dilation skips inputs between each filter tap. Stack convs with exponentially increasing dilation (1, 2, 4, 8,..., 512) and the receptive field grows exponentially, while the One of the model's internal numbers that gets adjusted as it learns.Full glossary → count grows linearly. WaveNet (van den Oord et al. 2016) used this to model raw audio with ~5000-sample receptive fields.
class DilatedBlock(nn.Module):
def __init__(self, channels: int, kernel: int, dilation: int):
super().__init__()
self.conv = nn.Conv1d(channels, channels, kernel,
padding=(kernel - 1) * dilation, dilation=dilation)
def forward(self, x):
out = self.conv(x)
# Causal: trim the right-pad so position t only depends on t-positions
return out[..., :x.size(-1)]That right-trim is the causal convolution trick. It makes the conv Generating text one piece at a time, where each new piece is chosen based on everything written so far.Full glossary → (position does not see position ), which lets you use it as a language model. WaveNet was an autoregressive raw-audio model, and its causal-dilated-conv stack is the conceptual ancestor of the modern Mamba / S4 / state-space-model lineage.
FIG 13.3.11
Time-series forecasting in practice
Forecasting is the prototypical sequence task: given , predict . The toolbox is wider than people from a deep-learning background expect.
Three baselines you must implement before reaching for an RNN:
- Naive last-value. . Surprisingly hard to beat on noisy data.
- Seasonal naive. where is the season length (7 for weekly, 24 for hourly, 12 for monthly). Strong on anything with periodicity.
- ARIMA / ETS. The classical statistical forecasting toolbox — ARIMA models the series as an Generating text one piece at a time, where each new piece is chosen based on everything written so far.Full glossary → process (each value regressed on its own recent past, ) plus a moving-average term; ETS fits exponential-smoothing trend and seasonality. You drive them through auto-tuning libraries (
statsmodels,pmdarima) rather than deriving them here; the lab notebook walks through their main diagnostic, the autocorrelation function, from its definition. Often within a few percent of an RNN on standard benchmarks.
If those don't suffice, then a small RNN:
class Forecaster(nn.Module):
def __init__(self, n_features: int, hidden: int = 64, horizon: int = 24):
super().__init__()
self.rnn = nn.GRU(n_features, hidden, batch_first=True)
self.head = nn.Linear(hidden, horizon)
def forward(self, x):
# x: (B, L, n_features). Returns (B, horizon).
_, h = self.rnn(x)
return self.head(h.squeeze(0))Train with MSE on the rolling-window targets. Standardise features per-series. Use During training, feeding a sequence model the correct previous answer instead of its own guess so it learns faster.Full glossary → only if you are doing multi-step decoder-style outputs; for direct multi-horizon output (as above) it does not apply.
FIG 13.3.12
When to use RNNs in 2026
Transformers have eaten most of what RNNs used to be good for. Honest cases where RNNs still make sense:
- Tiny models on edge devices. A 100k-One of the model's internal numbers that gets adjusted as it learns.Full glossary → LSTM runs on a microcontroller. A transformer of similar capacity does not.
- Streaming Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → with strict latency budgets. RNNs have per-A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → memory and compute. Transformers without KV-cache are ; with KV-cache they are still memory.
- Very long sequences with structured dependencies. State-space models (Mamba, S4, S5) are the modern descendants of RNNs and beat transformers on sequences of length 10k+ in benchmarks like Long Range Arena. They are recurrent in a more carefully-engineered way.
- Pedagogy. Building an LSTM from scratch is the cleanest way to internalise the vanishing-A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → problem and the gate-based fix.
What you should not reach for an RNN for in 2026: general NLP, machine translation, dialog systems, language modeling at scale, document classification, or anything where a pre-trained transformer is available. The cost of pre-training has shifted the trade-off permanently.
FIG 13.4 · Safety lens · this chapter
RNNs are the architecture where memorisation in neural language models was first carefully studied, and several of those findings transferred directly to transformers.
Verbatim memorisation. Carlini et al. showed that sequence models can emit rare training strings under suitable prompting, and that exposure depends on factors such as duplication, model capacity, training dynamics, and decoding. A rare secret in training data is therefore a leak risk, not a guarantee that every rare string will be reproduced. The long-memory pathway can participate in both useful dependency tracking and example-specific memorisation, but one does not logically imply the other. Treat memorisation as an empirical property to measure with canaries and extraction tests.
Sampling-A dial that controls how much a model gambles on unlikely words versus sticking to the most likely one.Full glossary → manipulation. RNNs trained on language data generate plausible-looking text at temperature 0.7-1.0, increasingly random gibberish at higher temperatures. But at temperature 0 (greedy) they tend to enter short repeated loops ("the the the"). This is a well-known failure mode, and it transfers to transformers. The implication for safety is that attackers can choose the sampling temperature in many deployment settings (it is exposed as a One of the model's internal numbers that gets adjusted as it learns.Full glossary → in most APIs), and very low temperatures can extract higher-fidelity verbatim Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → while very high temperatures can defeat safety filters that pattern-match on common phrasings. See 26-pentest-redteam/genai-owasp-org-llm-top-10 §LLM06-sensitive-info-disclosure for the API-design lesson: do not let arbitrary user-controlled temperatures into a safety-critical Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → path.
Truncated BPTT limits credit assignment. With a truncation window of 35 steps, a particular update does not backpropagate through transitions earlier than that window. The recurrent state still carries information forward, and parameters learned from shorter/local signals can sometimes preserve useful longer-range behavior, so the model is not literally blind beyond 35 tokens. The limitation is that direct A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → credit for a loss cannot cross the truncation boundary. For safety-relevant sequence tasks, test behavior across and well beyond the training window; consider larger/variable windows or architectures with a more suitable long-context path.
Habits to adopt when you write RNN code:
- Audit your training data for rare strings. Hashes, secrets, personal identifiers. Their presence creates avoidable extraction risk; remove or redact them rather than assuming training will keep them private.
- Test extraction attacks before deployment. A 50-line script that samples with prefixes from your A separate batch of examples you check the model against while you're still tinkering, to see how it's doing.Full glossary → will tell you, viscerally, how much your model memorised. Run it.
- Constrain decoding controls in production APIs. Choose and document task-specific bounds for temperature, top-p, output length, and retries, then test the whole allowed range. There is no universal safety threshold such as temperature 0.5.
The memorisation question generalises forward. Distill's "Visualizing Memorization in RNNs" showed that LSTMs build position-and-content-specific memorisation cells that you can find with simple probes; the same finding generalises to transformers, where it gets the full mech-interp treatment in 22-anthropic-recent/2023-toy-double-descent-index and 19-nanda-blog/interlude-a-mechanistic-interpretability-analysis-of-grokking. Carlini's extraction-attack methodology (26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications §LLM06-sensitive-information-disclosure) is the canonical reference for the audit script above. another chapter picks up memorisation as a mech-interp problem; another chapter picks it up as a red-team problem.
FIG 13.5 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- Three classical forecasting baselines (naive, seasonal-naive, moving-average) on a synthetic ridership series whose trend and seasonality you know, so you can grade every forecast against ground truth.
- The autocorrelation function from its definition, checked against statsmodels, used to read the 12-month period straight off the data.
- A time-respecting train/val/test split and a windowing function, both property-tested for the one bug that quietly leaks the future into the past.
- A context-window forecaster (an NPLM-style MLP over the last L steps) that has to beat the seasonal-naive baseline to earn its keep.
- A vanilla RNN cell in ~12 lines of NumPy, its one-step BPTT gradient checked against torch.autograd, and the exploding-gradient failure staged then fixed with norm clipping.
- A char-level next-token model on an embedded names corpus, your hand-rolled recurrence reconciled element-for-element with nn.RNN, sampled at the end as the payoff.
~5 min on CPU · 105 cells · 16 checked exercises · runs in Colab
FIG 13.6 · Going further
24-founder-blogs/karpathy-rnn-effectivenessthe foundational blog post. Read once a year. The "Sonnet" and "Linux kernel" examples still hold up.
24-founder-blogs/olah-2015-08-understanding-lstmsthe canonical LSTM explainer. Read alongside Karpathy.
01-explorables/distill-memorization-in-rnnsthe cleanest visualisation of what an LSTM's cell state actually does. Open in a real browser; the figures are interactive.
01-explorables/distill-augmented-rnnspre-transformer "attention on RNNs" work (neural Turing machines, memory networks). Historical interest, but the mental model still serves.
16-d2l-sections/chapter_recurrent-modern__seq2seqthe d2l.ai chapter on encoder-decoder. Pairs with another chapter.
08-geron-notebooks/15_processing_sequences_using_rnns_and_cnnsGéron's chapter with code. Solid implementations and a long forecasting case study.
18-lilian-weng/2023-01-27-the-transformer-family-v2 §state-space-modelsthe modern descendants of RNNs (Mamba, S4, S5). Where to look if RNNs interest you in 2026 but you don't want a museum piece.
02-code-refs/amidi-cs230-rnnthe Stanford CS230 cheat sheet. Excellent one-page reference for the equations.
FIG 13.7 · What this enables
Chapters you can now read, with the connecting idea written out.
The encoder-decoder of section 9 is exactly the architecture that gets attention bolted onto it. Bahdanau attention is the next thing.
Once you understand encoder-decoder seq2seq and have felt the bottleneck of a single context vector, the transformer's motivation is obvious. This chapter is the pedagogical predecessor.
Some of the cleanest interpretability work on sequence models was done first on LSTMs (
distill-memorization-in-rnns, Karpathy's cell-level analysis). The methods generalised.
FIG 13.8 · 25 sources
- 01-explorables/distill-augmented-rnns
- 01-explorables/distill-memorization-in-rnns
- 01-explorables/jalammar-visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention
- 02-code-refs/amidi-cs230-rnn
- 04-stanford/cs231n-rnn
- 08-geron-notebooks/15_processing_sequences_using_rnns_and_cnns
- 08-geron-notebooks/16_nlp_with_rnns_and_attention
- 13-fastbook/10_nlp
- 13-fastbook/12_nlp_dive
- 16-d2l-sections/chapter_recurrent-modern__bi-rnn
- 16-d2l-sections/chapter_recurrent-modern__deep-rnn
- 16-d2l-sections/chapter_recurrent-modern__encoder-decoder
- 16-d2l-sections/chapter_recurrent-modern__gru
- 16-d2l-sections/chapter_recurrent-modern__lstm
- 16-d2l-sections/chapter_recurrent-modern__seq2seq
- 16-d2l-sections/chapter_recurrent-neural-networks__bptt
- 16-d2l-sections/chapter_recurrent-neural-networks__rnn
- 16-d2l-sections/chapter_recurrent-neural-networks__rnn-scratch
- 16-d2l-sections/chapter_recurrent-neural-networks__sequence
- 18-lilian-weng/2017-07-08-stock-rnn-part-1
- 18-lilian-weng/2019-11-10-self-supervised
- 18-lilian-weng/2023-01-27-the-transformer-family-v2
- 24-founder-blogs/karpathy-rnn-effectiveness
- 24-founder-blogs/olah-2015-08-understanding-lstms
- 26-pentest-redteam/genai-owasp-org-llm-top-10