PROMETHEUS OS
NATIVE AI OPERATING SYSTEM
[INIT] Prometheus OS loading...
[CORE] Neural interface initializing...
[AI] LLM router connecting...
[MEM] Memory graph loading...
[AGT] Agent chains on standby...
[SEC] Security protocols active...
[NET] Provider mesh established...
[UI] Workspace rendering...
[DONE] All systems nominal.
Welcome to PROMETHEUS OS.
READY · OFFLINE · SECURE · PRIVATE
🎨 THEME ENGINE
PROMETHEUS OS · 23 THEMES
Trial version: Dark + Light themes only.
Full theme access with PROMETHEUS OS license.
Dark theme active
Experience all 23 themes in PROMETHEUS OS

Recurrent Networks and Sequence Modelling

Intermediate 17 min· 4 exercises

Write out and implement RNN, LSTM and GRU computations, explain vanishing gradients through backpropagation through time, and trace how encoder-decoder attention led directly to the transformer.

By the end of this lesson you will be able to
  • Write the forward equations of a vanilla RNN, an LSTM and a GRU and compute a step by hand
  • Derive why gradients vanish or explode in backpropagation through time and apply clipping and truncation
  • Explain how gating and the additive cell update let LSTMs preserve gradient flow
  • Implement and train a recurrent language model and an additive attention module in PyTorch
  • Explain how Bahdanau attention removed the seq2seq bottleneck and why transformers then replaced recurrence
  • Choose when a recurrent or recurrent-style model is still the right tool

Before this: Neural Networks and Backpropagation, Training Deep Networks in Practice

Why sequence models matter

Language, speech, sensor readings, prices, clicks and DNA are all sequences: ordered data where meaning depends on what came before. Recurrent neural networks (RNNs) were the first neural architecture built for this, and from roughly 2014 to 2017 the LSTM variant powered production machine translation, speech recognition and keyboard prediction. Transformers have since taken over most of those jobs, but you cannot really understand transformers — or the recurrent comeback in state-space models — without understanding what RNNs did, why they were hard to train, and which problem attention was invented to solve.

Intuition: reading with a running summary

Imagine reading a novel one word at a time while keeping a single index card of notes. After each word you rewrite the card based on the old card and the new word. You never look back at earlier pages; everything you remember must fit on the card. That card is the RNN's hidden state. It gives the model memory of unlimited span in principle, at constant cost per step. The catch is that the card is small and gets rewritten every step, so details from 200 words ago tend to be overwritten — and, as we will see, the training signal that would teach the model to keep them fades away even faster.

Sequence modelling tasks

Let x1,…,xTx_1, \dots, x_T be an input sequence of vectors xt∈Rdx_t \in \mathbb{R}^d. Common task shapes:

  • Many-to-one: classify a whole sequence (sentiment, activity recognition). Use the final hidden state or a pooled summary.
  • Many-to-many, aligned: one output per input (part-of-speech tagging, frame-level speech labels).
  • Language modelling: predict xt+1x_{t+1} from x1:tx_{1:t}, i.e. model p(x1,…,xT)=∏tp(xt∣x<t)p(x_1,\dots,x_T) = \prod_t p(x_t \mid x_{<t}).
  • Sequence-to-sequence: map one sequence to another of different length (translation, summarization). Needs an encoder–decoder design.

The vanilla RNN

At each step tt the RNN updates a hidden state ht∈Rnh_t \in \mathbb{R}^n and emits an output:

at=Whhht−1+Wxhxt+bh,ht=tanh⁡(at),y^t=Whyht+bya_t = W_{hh} h_{t-1} + W_{xh} x_t + b_h, \qquad h_t = \tanh(a_t), \qquad \hat{y}_t = W_{hy} h_t + b_y

Here Wxh∈Rn×dW_{xh} \in \mathbb{R}^{n \times d} maps the input, Whh∈Rn×nW_{hh} \in \mathbb{R}^{n \times n} is the recurrent matrix, WhyW_{hy} maps to outputs, and h0h_0 is usually zeros. The same weights are used at every time step — weight sharing across time, the analogue of a convolution's sharing across space. That is why one RNN can process sequences of any length.

Worked example: a three-step scalar RNN

Take n=d=1n = d = 1, Whh=w=0.5W_{hh} = w = 0.5, Wxh=u=1W_{xh} = u = 1, bh=0b_h = 0, h0=0h_0 = 0 and inputs x=(1,0.5,−1)x = (1, 0.5, -1).

  1. h1=tanh⁡(0.5⋅0+1)=tanh⁡(1)=0.7616h_1 = \tanh(0.5 \cdot 0 + 1) = \tanh(1) = 0.7616
  2. h2=tanh⁡(0.5⋅0.7616+0.5)=tanh⁡(0.8808)=0.7068h_2 = \tanh(0.5 \cdot 0.7616 + 0.5) = \tanh(0.8808) = 0.7068
  3. h3=tanh⁡(0.5⋅0.7068−1)=tanh⁡(−0.6466)=−0.5694h_3 = \tanh(0.5 \cdot 0.7068 - 1) = \tanh(-0.6466) = -0.5694

The state carries information forward: h2h_2 depends on x1x_1 through h1h_1.

Backpropagation through time

Training unrolls the network across the TT steps into a deep feed-forward graph with shared weights and applies ordinary backpropagation; this is backpropagation through time (BPTT). With total loss L=∑tLtL = \sum_t L_t, the gradient for the recurrent weights sums contributions from every step, and each contribution travels back through a chain of Jacobians:

∂Lt∂hk=∂Lt∂ht∏j=k+1t∂hj∂hj−1,∂hj∂hj−1=diag⁡ ⁣(1−hj2) Whh\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}}, \qquad \frac{\partial h_j}{\partial h_{j-1}} = \operatorname{diag}\!\big(1 - h_j^2\big)\, W_{hh}

where 1−hj21 - h_j^2 is the derivative of tanh⁡\tanh evaluated elementwise. The gradient that tells step kk how it should have behaved to help the loss at step tt is multiplied by t−kt - k such Jacobians.

Vanishing and exploding gradients

Taking norms, with γ\gamma an upper bound on ∣tanh⁡′∣|\tanh'| (which is 1) and σmax⁡\sigma_{\max} the largest singular value of WhhW_{hh}:

∥∏j=k+1t∂hj∂hj−1∥≤(γ σmax⁡(Whh))t−k\left\lVert \prod_{j=k+1}^{t} \frac{\partial h_j}{\partial h_{j-1}} \right\rVert \le \big(\gamma\, \sigma_{\max}(W_{hh})\big)^{t-k}

If σmax⁡<1\sigma_{\max} < 1, the bound shrinks exponentially: long-range gradients vanish. If the largest singular value exceeds 1, gradients can grow exponentially and explode (Pascanu, Mikolov and Bengio, 2013, analyse both conditions; the problem itself was identified by Hochreiter in 1991 and Bengio, Simard and Frasconi in 1994).

Back to our scalar example: ∂h3/∂h2=w(1−h32)=0.5(1−0.3242)=0.3379\partial h_3/\partial h_2 = w(1 - h_3^2) = 0.5(1 - 0.3242) = 0.3379 and ∂h2/∂h1=0.5(1−0.4996)=0.2502\partial h_2 / \partial h_1 = 0.5(1 - 0.4996) = 0.2502, so ∂h3/∂h1=0.0845\partial h_3/\partial h_1 = 0.0845. After only two steps, 92% of the signal is gone. With a per-step factor of 0.5 for 20 steps the gradient scales by 0.520≈9.5×10−70.5^{20} \approx 9.5 \times 10^{-7}. Even a generous factor of 0.9 for 50 steps gives 0.00520.0052, while 1.1 for 50 steps gives about 117. The model can still represent long-range dependencies; it simply cannot learn them because the training signal never arrives.

Practical remedies:

  • Gradient clipping handles explosions: if ∥g∥>τ\lVert g \rVert > \tau, rescale g←g⋅τ/∥g∥g \leftarrow g \cdot \tau / \lVert g \rVert. Standard practice for every recurrent model.
  • Truncated BPTT: backpropagate through only the last KK steps (carrying the hidden state forward but detaching it from the graph). Cuts memory and compute, at the price of not learning dependencies longer than KK through gradients.
  • Gated architectures (LSTM, GRU) address vanishing gradients structurally.

Long short-term memory (LSTM)

The LSTM (Hochreiter and Schmidhuber, 1997) adds a separate cell state ctc_t that is updated additively, protected by learned gates — vectors of values in (0,1)(0, 1) produced by sigmoids that multiply other vectors elementwise, acting as soft switches. The forget gate was added by Gers, Schmidhuber and Cummins (2000); the standard modern form is:

ft=σ(Wfxt+Ufht−1+bf)forget gateit=σ(Wixt+Uiht−1+bi)input gateot=σ(Woxt+Uoht−1+bo)output gatec~t=tanh⁡(Wcxt+Ucht−1+bc)candidatect=ft⊙ct−1+it⊙c~tht=ot⊙tanh⁡(ct)\begin{aligned} f_t &= \sigma(W_f x_t + U_f h_{t-1} + b_f) &&\text{forget gate} \\ i_t &= \sigma(W_i x_t + U_i h_{t-1} + b_i) &&\text{input gate} \\ o_t &= \sigma(W_o x_t + U_o h_{t-1} + b_o) &&\text{output gate} \\ \tilde{c}_t &= \tanh(W_c x_t + U_c h_{t-1} + b_c) &&\text{candidate} \\ c_t &= f_t \odot c_{t-1} + i_t \odot \tilde{c}_t \\ h_t &= o_t \odot \tanh(c_t) \end{aligned}

σ\sigma is the logistic sigmoid and ⊙\odot is elementwise multiplication. The key line is the cell update. Along the cell path, ∂ct/∂ct−1=diag⁡(ft)\partial c_t / \partial c_{t-1} = \operatorname{diag}(f_t) (ignoring the indirect paths through the gates). If the network learns ft≈1f_t \approx 1 for a unit, the gradient flows back nearly unchanged for many steps — no repeated multiplication by WhhW_{hh} and no squashing derivative. A common initialization sets bfb_f to 1 or more so the network starts out remembering.

Worked LSTM step (scalar, ct−1=0.5c_{t-1} = 0.5): suppose the pre-activations are 1.01.0 for the forget gate, 0.00.0 for the input gate, 0.50.5 for the candidate and 2.02.0 for the output gate. Then f=σ(1)=0.7311f = \sigma(1) = 0.7311, i=σ(0)=0.5i = \sigma(0) = 0.5, c~=tanh⁡(0.5)=0.4621\tilde{c} = \tanh(0.5) = 0.4621, o=σ(2)=0.8808o = \sigma(2) = 0.8808. The cell becomes ct=0.7311⋅0.5+0.5⋅0.4621=0.3655+0.2311=0.5966c_t = 0.7311 \cdot 0.5 + 0.5 \cdot 0.4621 = 0.3655 + 0.2311 = 0.5966 and the output ht=0.8808⋅tanh⁡(0.5966)=0.8808⋅0.5346=0.4709h_t = 0.8808 \cdot \tanh(0.5966) = 0.8808 \cdot 0.5346 = 0.4709. The cell kept 73% of its old content and mixed in half of the new candidate.

Gated recurrent unit (GRU)

The GRU (Cho et al., 2014) merges the cell and hidden state and uses two gates. In the convention used by PyTorch and the original paper:

rt=σ(Wrxt+Urht−1+br)reset gatezt=σ(Wzxt+Uzht−1+bz)update gatent=tanh⁡(Wnxt+rt⊙(Unht−1+bhn)+bin)candidateht=(1−zt)⊙nt+zt⊙ht−1\begin{aligned} r_t &= \sigma(W_r x_t + U_r h_{t-1} + b_r) &&\text{reset gate} \\ z_t &= \sigma(W_z x_t + U_z h_{t-1} + b_z) &&\text{update gate} \\ n_t &= \tanh\big(W_n x_t + r_t \odot (U_n h_{t-1} + b_{hn}) + b_{in}\big) &&\text{candidate} \\ h_t &= (1 - z_t) \odot n_t + z_t \odot h_{t-1} \end{aligned}

When zt≈1z_t \approx 1 the unit copies its old state (a skip connection through time); when rt≈0r_t \approx 0 the candidate ignores the past. Some textbooks swap the roles of zz and $1 - z$; the model is equivalent but check the convention before comparing code with equations.

Parameter counts. With input size dd and hidden size nn, an LSTM layer has 4(nd+n2+n)4(nd + n^2 + n) parameters with one bias per gate; PyTorch stores two bias vectors, giving $4n(d + n) + 8n.AGRUhasthreegateblocks:. A GRU has three gate blocks: 3n(d+n) + 6n$ in PyTorch. For d=100d = 100, n=256n = 256: LSTM =4⋅256⋅356+2,048=366,592= 4 \cdot 256 \cdot 356 + 2{,}048 = 366{,}592; GRU =274,944= 274{,}944. GRUs are about 25% cheaper and often perform comparably; LSTMs are a slightly safer default on long dependencies.

Training a character-level LSTM in PyTorch

import torch, torch.nn as nn

text = "hello world, hello deep learning. " * 50
chars = sorted(set(text)); stoi = {c: i for i, c in enumerate(chars)}
data = torch.tensor([stoi[c] for c in text])

class CharLSTM(nn.Module):
    def __init__(self, vocab, emb=32, hidden=128):
        super().__init__()
        self.emb = nn.Embedding(vocab, emb)
        self.lstm = nn.LSTM(emb, hidden, batch_first=True)
        self.out = nn.Linear(hidden, vocab)
    def forward(self, x, state=None):
        h, state = self.lstm(self.emb(x), state)    # h: (B, T, hidden)
        return self.out(h), state                   # logits: (B, T, vocab)

model = CharLSTM(len(chars))
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
loss_fn = nn.CrossEntropyLoss()
T, B = 32, 16
for step in range(300):
    idx = torch.randint(0, len(data) - T - 1, (B,))
    x = torch.stack([data[i:i + T] for i in idx])
    y = torch.stack([data[i + 1:i + T + 1] for i in idx])   # next-character targets
    logits, _ = model(x)
    loss = loss_fn(logits.reshape(-1, len(chars)), y.reshape(-1))
    opt.zero_grad(); loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)  # tame explosions
    opt.step()
print(f"final loss {loss.item():.3f}")

Note batch_first=True (inputs shaped batch × time × features; the PyTorch default is time-first), the one-step shift between inputs and targets, and gradient clipping between backward() and step(). nn.LSTM returns per-step outputs plus the final (h, c) state, which you pass back in to continue generating.

Sequence-to-sequence: encoder–decoder

For translation, Sutskever, Vinyals and Le (2014) used one LSTM as an encoder that reads the source sentence into its final state, and a second LSTM as a decoder that starts from that state and generates the target one token at a time:

p(y1,…,yT′∣x1:T)=∏t=1T′p(yt∣y<t,  c),c=hTencp(y_1, \dots, y_{T'} \mid x_{1:T}) = \prod_{t=1}^{T'} p(y_t \mid y_{<t}, \; c), \qquad c = h^{enc}_T

Training uses teacher forcing: the decoder receives the true previous token as input rather than its own prediction. At inference it must consume its own outputs, a train–test mismatch known as exposure bias. Decoding uses greedy search or beam search, which keeps the BB most probable partial outputs at each step. (Cho et al., 2014 proposed a closely related RNN encoder–decoder in the same year.)

The weakness is the bottleneck: the entire source sentence must be squeezed into one fixed-size vector cc. Translation quality fell off sharply on long sentences. Sutskever et al. found that reversing the source word order helped, because it shortened the distance between the first source and first target words — a clue that path length was the real problem.

Bahdanau attention: the bridge to transformers

Bahdanau, Cho and Bengio (2014) removed the bottleneck. The encoder (a bidirectional RNN) keeps all its hidden states h1,…,hTh_1, \dots, h_T. At each decoder step tt, with previous decoder state st−1s_{t-1}, the model scores every source position, normalizes the scores with a softmax, and builds a fresh context vector:

et,i=va⊤tanh⁡(Wast−1+Uahi),αt,i=exp⁡(et,i)∑j=1Texp⁡(et,j),ct=∑i=1Tαt,i hie_{t,i} = v_a^\top \tanh(W_a s_{t-1} + U_a h_i), \qquad \alpha_{t,i} = \frac{\exp(e_{t,i})}{\sum_{j=1}^{T} \exp(e_{t,j})}, \qquad c_t = \sum_{i=1}^{T} \alpha_{t,i}\, h_i

WaW_a, UaU_a and vav_a are learned. The weights αt,i\alpha_{t,i} form a soft alignment: when generating a French word, the decoder learns to focus on the English words it translates. This is called additive attention; Luong et al. (2015) popularized the cheaper dot-product score et,i=st⊤hie_{t,i} = s_t^\top h_i.

Worked example. Three encoder states h1=(1,0)h_1 = (1, 0), h2=(0,1)h_2 = (0, 1), h3=(1,1)h_3 = (1, 1) and scores e=(2.0,1.0,0.1)e = (2.0, 1.0, 0.1). Softmax: e2.0=7.389e^{2.0} = 7.389, e1.0=2.718e^{1.0} = 2.718, e0.1=1.105e^{0.1} = 1.105, sum 11.21211.212, so α=(0.659,0.242,0.099)\alpha = (0.659, 0.242, 0.099). Context: c=0.659(1,0)+0.242(0,1)+0.099(1,1)=(0.758,0.341)c = 0.659(1,0) + 0.242(0,1) + 0.099(1,1) = (0.758, 0.341). The decoder now sees a weighted mix concentrated on position 1.

import torch, torch.nn as nn

class AdditiveAttention(nn.Module):
    def __init__(self, enc_dim, dec_dim, attn_dim):
        super().__init__()
        self.W = nn.Linear(dec_dim, attn_dim, bias=False)
        self.U = nn.Linear(enc_dim, attn_dim, bias=False)
        self.v = nn.Linear(attn_dim, 1, bias=False)
    def forward(self, s_prev, enc_h, mask=None):
        # s_prev: (B, dec_dim); enc_h: (B, S, enc_dim); mask: (B, S), True = real token
        e = self.v(torch.tanh(self.W(s_prev).unsqueeze(1) + self.U(enc_h))).squeeze(-1)
        if mask is not None:
            e = e.masked_fill(~mask, float("-inf"))     # never attend to padding
        alpha = torch.softmax(e, dim=-1)                 # (B, S)
        context = torch.bmm(alpha.unsqueeze(1), enc_h).squeeze(1)   # (B, enc_dim)
        return context, alpha

Attention gives every output step a direct, one-hop connection to every input position, so gradients no longer need to travel through dozens of recurrent steps. The transformer's insight (Vaswani et al., 2017) was to ask: if attention provides the long-range paths, do we need the recurrence at all?

Why transformers replaced RNNs

Property per layerRecurrent layerSelf-attention layer
ComputeO(n⋅d2)O(n \cdot d^2)O(n2⋅d)O(n^2 \cdot d)
Sequential steps (cannot parallelize)O(n)O(n)O(1)O(1)
Longest path between two positionsO(n)O(n)O(1)O(1)

Here nn is sequence length and dd the model width; the comparison follows Table 1 of Vaswani et al. (2017). The decisive row is the second. An RNN must compute ht−1h_{t-1} before hth_t, so training on a 2,048-token sequence takes 2,048 dependent steps that cannot be spread across thousands of GPU cores. A transformer processes all positions of a training sequence in parallel with large matrix multiplies — exactly what GPUs are built for. Parallel training made it practical to train much larger models on much more data, and the short path length made long-range dependencies learnable. Quadratic cost in nn was an acceptable price.

Where RNN-like models still matter

  • Streaming and on-device inference. An RNN processes each new input in constant time and memory, while a transformer's key–value cache grows with context. Streaming speech recognition (for example RNN-transducer models), keyword spotting and low-power sensors still use recurrent designs.
  • Time series and control with small datasets, where the recurrent inductive bias helps and sequences are modest in length.
  • The recurrent comeback. State-space models such as Mamba and linear-attention variants are trained in parallel like transformers but run as recurrences at inference, with a fixed-size state. Several 2025-era production language models mix such layers with attention. These are covered in beyond-transformers.

Common pitfalls

  • Forgetting gradient clipping: a single exploding batch produces NaN losses. Clip by global norm (values around 0.5–5 are typical).
  • Padding without masking: padded positions leak into final states, attention weights and losses. Use pack_padded_sequence or masks, and ignore_index in the loss.
  • Using the last time step of a padded batch as the sequence summary, which may be a padding position for shorter sequences.
  • Not detaching hidden state across truncated-BPTT chunks, so the graph grows forever and memory runs out.
  • Batch-first confusion: nn.LSTM defaults to (time, batch, feature).
  • Expecting an RNN to learn very long dependencies from gradient signal alone. If the dependency is hundreds of steps, use attention or a model designed for it.

In practice

  • For new NLP work, start with a pretrained transformer; reach for an LSTM/GRU only for small streaming or edge workloads, or as a quick baseline on short sequences.
  • Typical recurrent recipe: 1–3 layers, hidden size 128–1024, dropout between layers, Adam with clipping, forget-gate bias initialized positive, bidirectional layers when the whole sequence is available (not for generation).
  • For time series, compare against strong non-neural baselines and gradient-boosted trees before committing to recurrent nets (see time-series-forecasting).
  • If you need constant-memory inference over long contexts, evaluate modern state-space or hybrid models rather than classic LSTMs.

Key takeaways

  • An RNN reuses the same weights at every time step and carries information forward in a fixed-size hidden state.
  • Backpropagation through time multiplies one Jacobian per step, so gradients shrink or grow exponentially with distance.
  • LSTMs and GRUs use sigmoid gates and additive state updates that let gradients pass through many steps nearly unchanged.
  • Seq2seq models squeezed the source into one vector; attention let the decoder look back at every encoder state with learned weights.
  • Transformers won because self-attention trains in parallel across positions and connects any two positions in one step.
  • Recurrent computation survives where constant-memory streaming inference matters, and returns in state-space and linear-attention models.

Terms in this lesson

Hidden state
The vector an RNN carries from one time step to the next, summarizing everything it has read so far.
Backpropagation through time (BPTT)
Training a recurrent network by unrolling it across time steps and applying backpropagation to the resulting shared-weight graph.
Vanishing gradient
Exponential shrinkage of gradients as they are multiplied through many layers or time steps, preventing learning of long-range dependencies.
Gradient clipping
Rescaling the gradient vector whenever its norm exceeds a threshold, to prevent exploding updates.
LSTM
A gated recurrent architecture with a separately maintained cell state updated additively under control of forget, input and output gates.
GRU
A gated recurrent architecture with reset and update gates and no separate cell state, cheaper than an LSTM.
Teacher forcing
Training a decoder by feeding it the ground-truth previous token rather than its own prediction.
Additive (Bahdanau) attention
An attention mechanism that scores each encoder state with a small feed-forward network of the decoder state and that encoder state, then forms a softmax-weighted context vector.

Check your understanding

  1. 1.

    In a scalar tanh RNN with recurrent weight w = 0.8, suppose every hidden state equals 0.5. By what factor is the gradient multiplied per step going backward?

  2. 2.

    What structural feature of the LSTM most directly mitigates vanishing gradients?

  3. 3.

    How many parameters does torch.nn.GRU(input_size=64, hidden_size=128) have (one layer, PyTorch's two-bias convention)?

  4. 4.

    What problem in the original seq2seq model did Bahdanau attention solve?

  5. 5.

    You need on-device wake-word detection on a microcontroller that processes audio frames continuously with a strict, fixed memory budget. Which design is most appropriate?

Exercises

Work each problem before opening the solution.

  1. Exercise 1Core

    Compute the parameter count of nn.LSTM(input_size=64, hidden_size=128, num_layers=2) and of the equivalent nn.GRU. Then explain in one sentence why the second layer has more parameters than the first.

    Show solution

    PyTorch uses $4n(d+n) + 8n$ for an LSTM layer and $3n(d+n) + 6n$ for a GRU layer.

    LSTM

    • Layer 1 (d=64d = 64, n=128n = 128): 4⋅128⋅192+1,024=98,304+1,024=99,3284 \cdot 128 \cdot 192 + 1{,}024 = 98{,}304 + 1{,}024 = 99{,}328
    • Layer 2 (d=128d = 128, input is layer 1's hidden state): 4⋅128⋅256+1,024=131,072+1,024=132,0964 \cdot 128 \cdot 256 + 1{,}024 = 131{,}072 + 1{,}024 = 132{,}096
    • Total: 231,424231{,}424

    GRU

    • Layer 1: 3⋅128⋅192+768=74,4963 \cdot 128 \cdot 192 + 768 = 74{,}496
    • Layer 2: 3⋅128⋅256+768=99,0723 \cdot 128 \cdot 256 + 768 = 99{,}072
    • Total: 173,568173{,}568

    The second layer's input is the 128-dimensional hidden state of the first layer rather than the 64-dimensional raw input, so its input weight matrices are twice as wide. You can confirm with sum(p.numel() for p in nn.LSTM(64, 128, num_layers=2).parameters()).

  2. Exercise 2Core

    A tanh RNN with scalar recurrent weight w=0.8w = 0.8 has hidden states that hover around h=0.5h = 0.5. (a) Estimate how much the gradient from step tt is scaled when it reaches step t−10t-10 and step t−30t-30. (b) An LSTM unit on the same task learns a forget gate of 0.95. Ignoring indirect paths, how much of the gradient along the cell path survives 30 steps? (c) What does this mean for learning a dependency 30 steps long?

    Show solution

    (a) Per-step factor: w(1−h2)=0.8×0.75=0.6w(1 - h^2) = 0.8 \times 0.75 = 0.6. After 10 steps: 0.610≈0.00600.6^{10} \approx 0.0060. After 30 steps: 0.630≈2.2×10−70.6^{30} \approx 2.2 \times 10^{-7}.

    (b) Along the cell path the factor is f=0.95f = 0.95 per step: 0.9530≈0.2150.95^{30} \approx 0.215, so about 21% of the gradient survives.

    (c) For the vanilla RNN, the gradient signal from a 30-step dependency is several million times smaller than local signals, so in practice it is drowned out and the dependency is not learned. The LSTM preserves a usable fraction of the signal, so it can learn the dependency, and the forget gate itself can learn to sit even closer to 1 for units that must remember.

  3. Exercise 3Challenge

    Implement a GRU cell from the equations in this lesson using only tensor operations, and verify it matches torch.nn.GRUCell exactly when given the same weights. Note that PyTorch stores the three gate blocks stacked in weight_ih (3n x d) and weight_hh (3n x n) in the order reset, update, new.

    Show solution
    import torch, torch.nn as nn
    torch.manual_seed(0)
    d, n = 5, 4
    cell = nn.GRUCell(d, n)
    x, h_prev = torch.randn(3, d), torch.randn(3, n)
    
    def my_gru(x, h_prev, cell):
        gi = x @ cell.weight_ih.T + cell.bias_ih          # (B, 3n)
        gh = h_prev @ cell.weight_hh.T + cell.bias_hh     # (B, 3n)
        i_r, i_z, i_n = gi.chunk(3, dim=1)
        h_r, h_z, h_n = gh.chunk(3, dim=1)
        r = torch.sigmoid(i_r + h_r)                      # reset gate
        z = torch.sigmoid(i_z + h_z)                      # update gate
        n_t = torch.tanh(i_n + r * h_n)                   # r multiplies (U_n h + b_hn)
        return (1 - z) * n_t + z * h_prev
    
    with torch.no_grad():
        print(torch.allclose(my_gru(x, h_prev, cell), cell(x, h_prev), atol=1e-6))  # True
    

    Two details matter for an exact match: the reset gate multiplies the entire recurrent term including its bias bhnb_{hn}, and the final interpolation puts zz on the old state. Getting either wrong still gives a working GRU-like cell but will fail the allclose check.

  4. Exercise 4Challenge

    A decoder computes additive-attention scores e=(1,3,0)e = (1, 3, 0) over encoder states h1=(2,0)h_1 = (2, 0), h2=(0,1)h_2 = (0, 1), h3=(1,1)h_3 = (1, 1). (a) Compute the attention weights and context vector. (b) Position 3 turns out to be padding. Recompute with correct masking. (c) Explain what goes wrong in training if padding is not masked.

    Show solution

    (a) Subtract the max for stability: exp⁡(1−3)=0.1353\exp(1-3) = 0.1353, exp⁡(0)=1\exp(0) = 1, exp⁡(−3)=0.0498\exp(-3) = 0.0498; sum =1.1851= 1.1851. Weights: α=(0.1142,0.8438,0.0420)\alpha = (0.1142, 0.8438, 0.0420). Context: c=0.1142(2,0)+0.8438(0,1)+0.0420(1,1)=(0.2284+0.0420,  0.8438+0.0420)=(0.2704,0.8858)c = 0.1142(2,0) + 0.8438(0,1) + 0.0420(1,1) = (0.2284 + 0.0420,\; 0.8438 + 0.0420) = (0.2704, 0.8858).

    (b) Masking sets e3=−∞e_3 = -\infty, so it gets weight 0. Softmax of (1,3)(1, 3): exp⁡(−2)=0.1353\exp(-2) = 0.1353, 11; sum 1.13531.1353; α=(0.1192,0.8808,0)\alpha = (0.1192, 0.8808, 0). Context: c=(0.2384,0.8808)c = (0.2384, 0.8808).

    (c) Without masking, the model attends to padding vectors whose content depends on how the batch was padded, so outputs change with batch composition, some probability mass is wasted, and the model can learn spurious cues from sequence length. Results then differ between batched training and single-example inference.

Further reading

Go further

Sources

Fact-checked and last reviewed 2026-11-05

Stay in the loop
Prometheus OS™ · Native AI OS · Built for the future © 2025 - 2026