Ch. 13

Sequences & Time Series

RNN, LSTM, GRU, why vanishing-gradient is structural, when to use RNNs in 2026 (rarely).

RNNLSTMGRU

FIG 13 · Explainer video


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 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

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 cost every time, and the model has no 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 recurrent network does one thing differently: it maintains a that is updated at every position, using the same weights at every position. Formally, given a sequence x1,x2,,xTx_1, x_2, \ldots, x_T:

ht=f(ht1,xt)h_t = f(h_{t-1}, x_t)

where ff is a learned function. The output at position tt is some function of hth_t. The same parameters are shared across all positions. This is the recurrent analogue of the convolutional -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 and current input, followed by a tanh:

ht=tanh(Whhht1+Wxhxt+bh)h_t = \tanh(W_{hh} h_{t-1} + W_{xh} x_t + b_h)

The output, if the task is many-to-many synchronised:

yt=Whyht+byy_t = W_{hy} h_t + b_y

That is the whole equation. Three matrices, two vectors, one nonlinearity. Karpathy's blog has this in 4 lines of NumPy.

nn.RNN vs. a from-scratch vanilla RNN (tanh recurrence)

DL primitive
LIBRARY
rnn = 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.
FROM SCRATCH
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, ys

from scratch: draft.md §2 (The vanilla RNN): VanillaRNN (NumPy) — not present in solution.py

  1. 1nn.RNN(input_size, hidden_size, nonlinearity='tanh') weights weight_ih/weight_hh W_xh, W_hh and the line h = tanh(W_hh @ h + W_xh @ x + b_h)
  2. 2output (the (B, T, H) returned tensor) hs — the list of hidden states collected at every timestep
  3. 3h_final (the (1, B, H) last state) the final h after the for-loop over x_seq
  4. 4the implicit zero initial hidden state h = np.zeros((hidden_size, 1)) before the loop
  5. 5the 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 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 flow backwards through the same shared weights at every timestep. This is called through time (BPTT). The unrolling is conceptual; in code it is the same backprop you already know, but with loss.backward traversing TT time steps before reaching the parameters.

The gradient of the loss at position tt with respect to the at position k<tk < t involves a product of Jacobians:

Lthk=Lthtj=k+1thjhj1\frac{\partial L_t}{\partial h_k} = \frac{\partial L_t}{\partial h_t} \prod_{j=k+1}^{t} \frac{\partial h_j}{\partial h_{j-1}}

Each Jacobian factor has the form diag(tanh())Whh\text{diag}(\tanh'(\cdot)) W_{hh}. 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 WhhW_{hh} 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 tkt - k grows; if above 1, it explodes. This is the matrix version of d2l's observation that repeated powers of WhhW_{hh}^\top 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:

  • . 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.
Python
# 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- problem in RNNs is not a issue. It is a structural property of the architecture. The at position tt has to flow through a tanh\tanh at every position from 1 to tt. The derivative of tanh\tanh is bounded by 1 (and is much smaller almost everywhere). The product of tt such terms, multiplied by powers of WhhW_{hh}, 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 ctc_t and a hth_t. The cell state is the long-term memory; the hidden state is the working memory and the output.

Four gates, each a -activated linear function of [ht1,xt][h_{t-1}, x_t]:

  • ftf_t (forget gate): which cell-state components to keep
  • iti_t (input gate): which new candidate values to add
  • c~t\tilde{c}_t (candidate values): the tanh-squashed proposal
  • oto_t (output gate): which cell-state components to expose as the hidden state

The update:

ft=σ(Wf[ht1,xt]+bf)f_t = \sigma(W_f [h_{t-1}, x_t] + b_f) it=σ(Wi[ht1,xt]+bi)i_t = \sigma(W_i [h_{t-1}, x_t] + b_i) c~t=tanh(Wc[ht1,xt]+bc)\tilde{c}_t = \tanh(W_c [h_{t-1}, x_t] + b_c) ct=ftct1+itc~tc_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t ot=σ(Wo[ht1,xt]+bo)o_t = \sigma(W_o [h_{t-1}, x_t] + b_o) ht=ottanh(ct)h_t = o_t \odot \tanh(c_t)

The key line is the cell-state update: ct=ftct1+itc~tc_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t. This is an additive update on the cell state, gated multiplicatively. If ft1f_t \approx 1 and it0i_t \approx 0, then ctct1c_t \approx c_{t-1} 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 primitive
LIBRARY
lstm = 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))
FROM SCRATCH
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_new

from scratch: lab/solution.py: LSTMCell

  1. 1nn.LSTM(input_size, hidden_size) weight matrices weight_ih/weight_hh nn.Linear(input_size + hidden_size, 4 * hidden_size) — one projection of cat([h, x]) producing all four gate pre-activations
  2. 2the input/forget/candidate/output gates computed internally by cuDNN proj.split(hidden, -1) -> f, i, g, o, then sigmoid(f), sigmoid(i), tanh(g), sigmoid(o)
  3. 3the cell-state recurrence folded inside lstm(x) c_new = f * c + i * g — the additive long-term-memory update
  4. 4the hidden-state / output the layer returns h_new = o * torch.tanh(c_new)
  5. 5the 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. 6h_n, c_n returned as the last-timestep state the 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 into a single state vector, and uses only two gates: an update gate ztz_t (how much of the new candidate to mix in) and a reset gate rtr_t (how much of the previous state to use when computing the candidate).

zt=σ(Wz[ht1,xt])z_t = \sigma(W_z [h_{t-1}, x_t]) rt=σ(Wr[ht1,xt])r_t = \sigma(W_r [h_{t-1}, x_t]) h~t=tanh(W[rtht1,xt])\tilde{h}_t = \tanh(W [r_t \odot h_{t-1}, x_t]) ht=(1zt)ht1+zth~th_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t

GRUs have about 75% of the parameters of an equivalent LSTM (three matrices versus four). On most sequence tasks the 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:

Python
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.LSTM

FIG 13.3.7

Bidirectional RNNs

For tasks where you have the full sequence available at 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.

Python
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 concatenated

only works for non-causal tasks. You cannot use it for language modeling, where position tt should not see position t+kt + k. 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 . Stacking gives the model more representational capacity per timestep.

Python
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 layer

In 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 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 at a time (translation, summarisation, dialog), the standard pre-transformer architecture was the 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 (typically the final ). 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.

Python
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 (vocab×d)(\text{vocab} \times d) matrix whose ii-th row is the dd-dimensional vector for token index ii, fetched directly by index and trained with the rest of the model. (This is the token- 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 is the fixed set of tokens the model can read or emit.

At training time, the decoder is fed the ground-truth previous tokens (); at 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 decoding, and taking the single highest- 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 . another chapter develops the attention layer that sits on top of this. The 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, ct=tαt,thtc_{t'} = \sum_t \alpha_{t',t}\, h_t — a weighted sum of all the encoder hidden states, where the weights α\alpha are a 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 is to sequences what a 2D convolution is to images. A of size kk slides along the sequence axis and produces a new sequence. Same arithmetic for output shape: Lout=(Lin+2Pk)/S+1L_{\text{out}} = (L_{\text{in}} + 2P - k)/S + 1.

Python
# 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 . 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 dd skips d1d - 1 inputs between each filter tap. Stack convs with exponentially increasing dilation (1, 2, 4, 8,..., 512) and the receptive field grows exponentially, while the count grows linearly. WaveNet (van den Oord et al. 2016) used this to model raw audio with ~5000-sample receptive fields.

Python
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 (position tt does not see position t+kt + k), 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 x1,,xtx_1, \ldots, x_t, predict xt+1,,xt+kx_{t+1}, \ldots, x_{t+k}. 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. x^t+1=xt\hat{x}_{t+1} = x_t. Surprisingly hard to beat on noisy data.
  • Seasonal naive. x^t+1=xt+1s\hat{x}_{t+1} = x_{t+1-s} where ss 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 process (each value regressed on its own recent past, P(xtxt1,,xtτ)P(x_t \mid x_{t-1}, \ldots, x_{t-\tau})) 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:

Python
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 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- LSTM runs on a microcontroller. A transformer of similar capacity does not.
  • Streaming with strict latency budgets. RNNs have O(1)O(1) per- memory and compute. Transformers without KV-cache are O(L)O(L); with KV-cache they are still O(L)O(L) 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- 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- 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 in most APIs), and very low temperatures can extract higher-fidelity verbatim 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 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 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 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-effectiveness

    the foundational blog post. Read once a year. The "Sonnet" and "Linux kernel" examples still hold up.

  • 24-founder-blogs/olah-2015-08-understanding-lstms

    the canonical LSTM explainer. Read alongside Karpathy.

  • 01-explorables/distill-memorization-in-rnns

    the cleanest visualisation of what an LSTM's cell state actually does. Open in a real browser; the figures are interactive.

  • 01-explorables/distill-augmented-rnns

    pre-transformer "attention on RNNs" work (neural Turing machines, memory networks). Historical interest, but the mental model still serves.

  • 16-d2l-sections/chapter_recurrent-modern__seq2seq

    the d2l.ai chapter on encoder-decoder. Pairs with another chapter.

  • 08-geron-notebooks/15_processing_sequences_using_rnns_and_cnns

    Gé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-models

    the 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-rnn

    the 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
  1. 01-explorables/distill-augmented-rnns
  2. 01-explorables/distill-memorization-in-rnns
  3. 01-explorables/jalammar-visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention
  4. 02-code-refs/amidi-cs230-rnn
  5. 04-stanford/cs231n-rnn
  6. 08-geron-notebooks/15_processing_sequences_using_rnns_and_cnns
  7. 08-geron-notebooks/16_nlp_with_rnns_and_attention
  8. 13-fastbook/10_nlp
  9. 13-fastbook/12_nlp_dive
  10. 16-d2l-sections/chapter_recurrent-modern__bi-rnn
  11. 16-d2l-sections/chapter_recurrent-modern__deep-rnn
  12. 16-d2l-sections/chapter_recurrent-modern__encoder-decoder
  13. 16-d2l-sections/chapter_recurrent-modern__gru
  14. 16-d2l-sections/chapter_recurrent-modern__lstm
  15. 16-d2l-sections/chapter_recurrent-modern__seq2seq
  16. 16-d2l-sections/chapter_recurrent-neural-networks__bptt
  17. 16-d2l-sections/chapter_recurrent-neural-networks__rnn
  18. 16-d2l-sections/chapter_recurrent-neural-networks__rnn-scratch
  19. 16-d2l-sections/chapter_recurrent-neural-networks__sequence
  20. 18-lilian-weng/2017-07-08-stock-rnn-part-1
  21. 18-lilian-weng/2019-11-10-self-supervised
  22. 18-lilian-weng/2023-01-27-the-transformer-family-v2
  23. 24-founder-blogs/karpathy-rnn-effectiveness
  24. 24-founder-blogs/olah-2015-08-understanding-lstms
  25. 26-pentest-redteam/genai-owasp-org-llm-top-10