🎯 What You'll Learn
- Understand why sequential data requires memory-based models — the four sequence problem types
- Trace the forward pass of a vanilla RNN step by step, including the hidden state update
- Understand backpropagation through time (BPTT) and why it causes vanishing gradients
- Understand LSTM's cell state and three gates (forget, input, output) intuitively and mathematically
- Use
nn.LSTMin PyTorch and correctly handle the(h_n, c_n)output tuple - Understand GRU as a simplified LSTM and when to choose each
- Apply bidirectional RNNs, stacked layers, and gradient clipping correctly
- Build an LSTM sentiment classifier on movie reviews
Every model we've seen so far treats each input independently. An MLP classifying emails doesn't remember previous emails. A CNN classifying images doesn't know which image came before. But language doesn't work like that. The word "bank" means something completely different in "river bank" vs "bank account" — you need memory of the previous word to understand the current one. Sequence models are neural networks with memory. They process data one step at a time, maintaining a hidden state that acts like a dynamic, continuously-updated memory of everything seen so far.
1 Why Sequential Data Needs Special Treatment
Not all data is a fixed-size vector. Text, speech, music, time series, video — these are all sequential: the meaning of each element depends on what came before (and often what comes after). A standard MLP can't handle this for two fundamental reasons:
- Fixed input size: an MLP requires inputs of a fixed predetermined size. But a sentence can be 3 words or 300 words. A time series can have 10 measurements or 10,000.
- No order awareness: an MLP with inputs [cat, sat, the, on, mat] and [the, mat, on, sat, cat] produces different outputs, but both are just 5 independent tokens — the model has no mechanism to understand that the first is a sentence and the second is scrambled nonsense.
The Four Sequence Problem Types
Most sequence learning problems fit into one of four categories. Understanding which type your problem falls into determines the architecture:
| Type | Input | Output | Example |
|---|---|---|---|
| One-to-one | Single item | Single item | Image classification (MLP/CNN) |
| One-to-many | Single item | Sequence | Image captioning (image → words) |
| Many-to-one | Sequence | Single item | Sentiment analysis (words → positive/negative) |
| Many-to-many | Sequence | Sequence | Machine translation (English → French) |
Sequential Data Examples
import torch
# Text: sequence of word indices, variable length
review = [42, 17, 891, 0, 4, 230, ...] # "This movie was great..."
# Shape: (sequence_length,)
# Time series: sensor readings over time
stock_prices = torch.randn(252, 5) # 252 trading days, 5 features
# Shape: (time_steps, features)
# Audio: samples in a waveform
audio = torch.randn(16000) # 1 second at 16kHz
# Shape: (num_samples,)
# Video: frames over time
video = torch.randn(30, 3, 224, 224) # 30 frames, RGB, 224x224
# Shape: (frames, channels, height, width)
# When batching: sequences get an extra batch dimension
# Shape convention: (batch_size, sequence_length, features) — with batch_first=True
# OR: (sequence_length, batch_size, features) — PyTorch default
2 The Vanilla RNN: Memory Through Hidden State
A Recurrent Neural Network (RNN) processes sequences one element at a time. At each step, it reads the current input and its own previous output (the hidden state), producing an updated hidden state. The hidden state is the network's "memory" — it summarises everything seen so far.
The Single-Step Update
At each time step t, the RNN performs one update:
ht = tanh(Wh · ht-1 + Wx · xt + b)
- xt: current input (e.g., a word embedding)
- ht-1: hidden state from previous step (the "memory")
- Wh: weight matrix for the hidden state (same at every step!)
- Wx: weight matrix for the input (same at every step!)
- tanh: squishes output to [−1, +1]
- ht: new hidden state — the updated memory
The same weights Wh and Wx are used at every time step. This is the RNN's equivalent of weight sharing in CNNs (same filter at every spatial position). It means the number of parameters doesn't grow with sequence length — a crucial property for handling variable-length sequences. An RNN with hidden size 256 and input size 128 has the same parameter count for a 10-word sentence as for a 1,000-word essay.
Manual RNN Forward Pass
import torch
import torch.nn as nn
import torch.nn.functional as F
# Manual single-step RNN to build intuition
class ManualRNNCell(nn.Module):
def __init__(self, input_size, hidden_size):
super().__init__()
self.Wx = nn.Linear(input_size, hidden_size) # weights for input
self.Wh = nn.Linear(hidden_size, hidden_size, bias=False) # weights for hidden state
def forward(self, x_t, h_prev):
"""
x_t: (batch, input_size)
h_prev: (batch, hidden_size)
returns h_t: (batch, hidden_size)
"""
return torch.tanh(self.Wx(x_t) + self.Wh(h_prev))
# Process a sequence of 10 steps
input_size, hidden_size, batch_size = 20, 64, 4
seq_len = 10
cell = ManualRNNCell(input_size, hidden_size)
# Initial hidden state: all zeros
h = torch.zeros(batch_size, hidden_size)
# Process sequence step by step
sequence = torch.randn(seq_len, batch_size, input_size) # (T, B, features)
hidden_states = []
for t in range(seq_len):
x_t = sequence[t] # (batch, input_size)
h = cell(x_t, h) # update hidden state
hidden_states.append(h)
print(f"Step {t+1:2d}: h range [{h.min():.3f}, {h.max():.3f}]")
# Final hidden state h contains a summary of the entire sequence
print(f"\nFinal hidden state shape: {h.shape}")
Using PyTorch's nn.RNN
import torch
import torch.nn as nn
# nn.RNN handles the loop for you
rnn = nn.RNN(
input_size=20, # size of each input vector
hidden_size=64, # size of hidden state
num_layers=1, # number of stacked RNN layers
batch_first=True, # input shape: (batch, seq_len, features)
nonlinearity='tanh' # 'tanh' or 'relu'
)
# Input: batch of 4 sequences, each 10 steps long, 20 features per step
x = torch.randn(4, 10, 20) # (batch, seq_len, input_size)
h0 = torch.zeros(1, 4, 64) # (num_layers, batch, hidden_size)
output, h_n = rnn(x, h0)
print(f"output shape: {output.shape}") # (4, 10, 64) — hidden state at every step
print(f"h_n shape: {h_n.shape}") # (1, 4, 64) — final hidden state
3 Unrolling the RNN Through Time
To understand backpropagation in RNNs, it helps to "unroll" the RNN — visualize it as a deep feedforward network where each "layer" is one time step.
For a sequence of length T, the unrolled RNN has T identical layers. The weights Wh and Wx are shared (the same matrix is used at every layer), but each "layer" still needs to pass gradients to the layer before it during backpropagation. This process is called Backpropagation Through Time (BPTT).
The canonical unrolled-RNN picture: one cell, drawn once per time step. The hidden state ht is the only thing that carries information from step to step (blue, horizontal); the input xt arrives fresh at each step (vertical, from below) and the output yt is read off at each step (vertical, upward). Because the weights are shared across all four copies, backpropagation through this unrolled graph must sum gradients contributed at every time step — this is exactly BPTT.
BPTT: Gradients Flow Backwards Through Time
When we compute a loss at the final step (e.g., the final hidden state hT is used for classification), we compute:
∂L/∂h0 = ∂L/∂hT · ∂hT/∂hT-1 · ∂hT-1/∂hT-2 · ... · ∂h1/∂h0
This is a product of T Jacobian matrices — one per time step. If T=100 (a short paragraph), we're multiplying 100 matrices together. This is exactly the vanishing gradient problem, but happening across time instead of layers.
import torch
import torch.nn as nn
# Demonstrate gradient flow through many time steps
rnn = nn.RNN(input_size=10, hidden_size=32, batch_first=True)
# Sequence of length 100 (a short paragraph)
x = torch.randn(1, 100, 10, requires_grad=True)
h0 = torch.zeros(1, 1, 32)
output, h_n = rnn(x, h0)
# Loss depends only on the FINAL hidden state — must propagate back 100 steps
loss = h_n.sum()
loss.backward()
# Examine gradient magnitude at early vs late positions
grads = x.grad # (1, 100, 10)
early_grad_norm = grads[0, 0, :].norm().item() # gradient at step 1
late_grad_norm = grads[0, 99, :].norm().item() # gradient at step 100
print(f"Gradient norm at step 1 (earliest): {early_grad_norm:.6f}")
print(f"Gradient norm at step 100 (latest): {late_grad_norm:.6f}")
print(f"Ratio (late/early): {late_grad_norm / (early_grad_norm + 1e-10):.1f}x")
The gradient at step 1 is ~28,000 times smaller than the gradient at step 100. The model essentially cannot learn from information at the beginning of a 100-step sequence. This is the vanishing gradient problem in RNNs.
4 The Vanishing Gradient Problem in RNNs
The vanishing gradient problem in RNNs is more fundamental than the deep network version. Even with ReLU activations (which prevent vanishing gradients in standard deep networks), vanilla RNNs still struggle — because the recurrent weight matrix Wh is multiplied T times, and the tanh derivatives are at most 1.
Why tanh Causes Vanishing Gradients
The derivative of tanh(x) at x=0 is 1. But for any x ≠ 0, the derivative is less than 1 — and for large |x|, it's nearly 0. After T steps of BPTT, gradients are multiplied by T copies of this derivative. Even if each one is 0.9, after 100 steps: 0.9^100 ≈ 0.000027. A factor 1/37,000 — completely vanished.
import numpy as np
import matplotlib.pyplot as plt
def tanh_derivative(x):
return 1 - np.tanh(x) ** 2
x_vals = np.linspace(-3, 3, 200)
deriv = tanh_derivative(x_vals)
plt.figure(figsize=(8, 3))
plt.plot(x_vals, np.tanh(x_vals), label='tanh(x)', color='steelblue')
plt.plot(x_vals, deriv, label="tanh'(x) — at most 1.0", color='tomato')
plt.axhline(1.0, color='gray', linestyle='--', alpha=0.5)
plt.axhline(0.0, color='gray', linestyle='-', alpha=0.3)
plt.legend(); plt.xlabel('x'); plt.title("tanh and its derivative")
plt.tight_layout(); plt.show()
# Simulate gradient decay over 100 steps
# Assume each tanh derivative is a constant 0.8 (optimistic!)
T = 100
grad_at_step = [(0.8 ** (T - t)) for t in range(T)]
print(f"Gradient at step 1 (if deriv=0.8 per step): {0.8**99:.2e}")
print(f"Gradient at step 50: {0.8**50:.2e}")
print(f"Gradient at step 90: {0.8**10:.2e}")
print(f"Gradient at step 100 (final): {0.8**0:.2e}")
# Step 1: 2.04e-10 — effectively zero
# Step 100: 1.0 — gradient flows normally
The chart below makes this concrete. It plots gradient_magnitude = factor^n_steps — the fraction of the original gradient signal that survives after being back-propagated n_steps timesteps, assuming each step multiplies the signal by a constant factor (a stand-in for the tanh derivative × recurrent weight at that step). Drag the slider to see how sensitive the decay is to that one number:
factor = 0.90 — after 50 steps the gradient has shrunk to roughly 0.5% of its original size.
Vanilla RNNs can typically only retain information from the last 10–20 steps. A movie review might be 200 words long — the beginning of the review is essentially invisible to the final prediction. A sentence like "The food was terrible but the service, ambiance, music, decor, and prices were all... excellent" — the RNN might not remember "terrible" by the time it reaches the end. LSTM was designed specifically to solve this.
5 LSTM: The Solution to Long-Range Memory
Long Short-Term Memory (LSTM), introduced by Hochreiter & Schmidhuber in 1997, is the most widely used sequence model for its ability to remember information over hundreds of steps. The key innovation: a second state variable called the cell state (ct), alongside the hidden state (ht).
The Conveyor Belt Analogy
Think of the cell state as an airport baggage conveyor belt. Information placed on the belt at check-in (early time steps) can ride all the way to baggage claim (later time steps) with minimal interference — unless someone deliberately removes it (forget gate) or adds new luggage (input gate). The hidden state, by contrast, is like the passenger — it actively interacts with everything at each step.
Mathematically, the cell state update is:
ct = ft ⊙ ct-1 + it ⊙ gt
This is addition (not matrix multiplication). Gradients flow through addition essentially unchanged — no repeated Jacobian multiplication, no vanishing. The cell state is the gradient highway.
The Three Gates
Three sigmoid "gates" control what information enters and leaves the cell state. Each gate outputs values in (0, 1) — acting as a soft valve: 0 = fully closed, 1 = fully open.
- Forget gate ft: decides what to erase from the previous cell state ct-1
- Input gate it: decides what new information to write to the cell state
- Output gate ot: decides what to output as the hidden state ht
The gating concept: the forget gate multiplies the incoming cell state (deciding what to erase), the input gate scales a new candidate value before it's added in (deciding what to write), and the output gate controls how much of the resulting cell state is exposed as the new hidden state ht. Everything on the violet highway is addition and element-wise multiplication — never a matrix multiply — which is why gradients can flow along it without vanishing.
The gradient of the loss with respect to ct-1 goes through the forget gate: ∂ct/∂ct-1 = ft. This is an element-wise multiplication by a value in (0,1) — not a full matrix multiplication. Crucially, if the forget gate is near 1 (remember everything), the gradient is near 1 too. The LSTM can learn to keep the forget gate ≈ 1 for important information, providing a gradient signal that doesn't decay. This is the fundamental mechanism that allows LSTMs to learn dependencies spanning hundreds of time steps.
6 LSTM Gates in Detail
Let's work through the full LSTM equations, then verify them in PyTorch code.
Step-by-Step LSTM Equations
At each time step t, the LSTM concatenates ht-1 and xt into a single vector, then applies four separate linear transformations (one per gate/candidate) + activations:
import torch
import torch.nn as nn
class ManualLSTMCell(nn.Module):
"""
Manual LSTM cell — shows every gate explicitly.
Identical to nn.LSTMCell.
"""
def __init__(self, input_size, hidden_size):
super().__init__()
# All four transformations are typically batched into one
# large linear layer for efficiency, but written separately for clarity
self.forget_gate = nn.Linear(input_size + hidden_size, hidden_size)
self.input_gate = nn.Linear(input_size + hidden_size, hidden_size)
self.cell_gate = nn.Linear(input_size + hidden_size, hidden_size) # candidate
self.output_gate = nn.Linear(input_size + hidden_size, hidden_size)
def forward(self, x_t, h_prev, c_prev):
"""
x_t: (batch, input_size)
h_prev: (batch, hidden_size) — short-term memory
c_prev: (batch, hidden_size) — long-term memory (cell state)
"""
# Concatenate inputs and previous hidden state
combined = torch.cat([h_prev, x_t], dim=1) # (batch, input+hidden)
# ── Forget gate: what to erase from cell state ──
# sigmoid: output in (0,1); 0 = forget everything, 1 = remember everything
f = torch.sigmoid(self.forget_gate(combined)) # (batch, hidden)
# ── Input gate: what new info to add ──
i = torch.sigmoid(self.input_gate(combined)) # (batch, hidden)
# ── Candidate cell values (what the input gate selects from) ──
g = torch.tanh(self.cell_gate(combined)) # (batch, hidden)
# ── Cell state update (the gradient highway!) ──
# Old cell * forget gate + new candidate * input gate
c_t = f * c_prev + i * g # (batch, hidden)
# ── Output gate: what to expose as hidden state ──
o = torch.sigmoid(self.output_gate(combined)) # (batch, hidden)
# ── New hidden state ──
h_t = o * torch.tanh(c_t) # (batch, hidden)
return h_t, c_t
# Test the manual LSTM cell
cell = ManualLSTMCell(input_size=20, hidden_size=64)
x_t = torch.randn(4, 20) # batch=4
h_0 = torch.zeros(4, 64)
c_0 = torch.zeros(4, 64)
h_1, c_1 = cell(x_t, h_0, c_0)
print(f"h_1 shape: {h_1.shape}") # (4, 64)
print(f"c_1 shape: {c_1.shape}") # (4, 64)
print(f"h_1 range: [{h_1.min():.3f}, {h_1.max():.3f}]") # within [-1, 1] (tanh bounded)
print(f"c_1 range: [{c_1.min():.3f}, {c_1.max():.3f}]") # can grow beyond [-1, 1]
Using PyTorch's nn.LSTM
import torch
import torch.nn as nn
# nn.LSTM handles the full sequence loop
lstm = nn.LSTM(
input_size=20, # size of each input vector
hidden_size=64, # size of hidden state AND cell state
num_layers=2, # stacked LSTM layers
batch_first=True, # input: (batch, seq_len, features)
dropout=0.3, # dropout between LSTM layers (not after last)
bidirectional=False # see Section 8 for bidirectional
)
# Input: batch of 4 sequences, 10 steps, 20 features each
x = torch.randn(4, 10, 20)
# Initial states (both h and c for LSTM!)
# Shape: (num_layers * num_directions, batch, hidden_size)
h0 = torch.zeros(2, 4, 64) # num_layers=2
c0 = torch.zeros(2, 4, 64)
output, (h_n, c_n) = lstm(x, (h0, c0))
print(f"Input: {x.shape}")
print(f"output: {output.shape}") # (4, 10, 64) — hidden state at every step
print(f"h_n: {h_n.shape}") # (2, 4, 64) — final hidden state per layer
print(f"c_n: {c_n.shape}") # (2, 4, 64) — final cell state per layer
# For many-to-one tasks (e.g., sentiment), use only the final output
final_hidden = output[:, -1, :] # (4, 64) — last time step's hidden state
# OR equivalently: h_n[-1, :, :] — last layer's final hidden state
print(f"Final hidden: {final_hidden.shape}")
nn.LSTM returns (output, (h_n, c_n)) — three tensors in a nested structure. nn.RNN and nn.GRU return (output, h_n) — just two tensors. The most common LSTM bug is writing output, h_n = lstm(x, h0) when you need output, (h_n, c_n) = lstm(x, (h0, c0)). Always pass the initial states as a tuple for LSTM.
7 GRU: A Simplified LSTM
The Gated Recurrent Unit (GRU), introduced by Cho et al. in 2014, achieves similar performance to LSTM with a simpler architecture. The key simplification: GRU merges the cell state and hidden state into a single state, reducing from three gates to two.
GRU Gates
- Reset gate rt: how much of the past hidden state to "reset" (forget) when computing the candidate hidden state. Small rt means: ignore the past, focus on the current input.
- Update gate zt: how much of the old hidden state to keep vs how much to replace with the new candidate. zt=1: keep old state entirely; zt=0: fully replace with candidate.
The update equation combines forget and input gates into one:
ht = (1 − zt) ⊙ ht-1 + zt ⊙ ñt
When zt→0: ht ≈ ht-1 (copy old state — remember). When zt→1: ht ≈ ñt (use new content — update memory). This is the same as LSTM's forget+input gates working as a complementary pair.
LSTM tracks two states (cell state ct for long-term memory, hidden state ht for short-term output) with three gates controlling them. GRU merges both states into a single ht and controls it with just two gates: the reset gate (how much past to ignore when forming the candidate) and the update gate (how much old state to keep vs. replace) — the update gate effectively does the job of LSTM's forget and input gates combined.
import torch
import torch.nn as nn
# GRU — simpler than LSTM (no cell state, 2 gates instead of 3)
gru = nn.GRU(
input_size=20,
hidden_size=64,
num_layers=2,
batch_first=True,
dropout=0.3,
bidirectional=False
)
x = torch.randn(4, 10, 20)
h0 = torch.zeros(2, 4, 64) # only h (no cell state c!)
output, h_n = gru(x, h0) # GRU returns (output, h_n) — no c_n
print(f"GRU output: {output.shape}") # (4, 10, 64)
print(f"GRU h_n: {h_n.shape}") # (2, 4, 64)
# Parameter count comparison
lstm_params = sum(p.numel() for p in nn.LSTM(20, 64, batch_first=True).parameters())
gru_params = sum(p.numel() for p in nn.GRU(20, 64, batch_first=True).parameters())
print(f"\nLSTM parameters (same hidden_size): {lstm_params:,}")
print(f"GRU parameters (same hidden_size): {gru_params:,}")
print(f"GRU has {(lstm_params - gru_params)/lstm_params*100:.1f}% fewer parameters")
LSTM vs GRU: Which to Choose?
| Criterion | LSTM | GRU |
|---|---|---|
| Parameters | More (~33% more than GRU) | Fewer |
| Training speed | Slower | ~15% faster |
| Long sequences | Usually slightly better | Comparable |
| Small datasets | More prone to overfitting | Better (fewer params) |
| Recommendation | Long sequences, more data | Short sequences, quick experiments |
The empirical literature shows that LSTM and GRU often achieve nearly identical performance on the same task. The difference is rarely more than 0.5–1%. In practice: start with GRU (faster to experiment), then try LSTM if you have the compute budget. If neither gives good results, the issue is usually something else (too little data, wrong hyperparameters, insufficient depth) — not the LSTM vs GRU choice.
8 Practical Considerations
Production-quality RNN training requires several additional techniques beyond the basic architecture. These are standard practice and should be in every RNN implementation.
Bidirectional RNNs
A unidirectional RNN processes a sequence from left to right. At each position, it can only see the past. But for many tasks — sentiment analysis, named entity recognition, text classification — both past and future context matter. "I didn't enjoy the film" — the word "didn't" is more important if you also see "enjoy" after it.
import torch
import torch.nn as nn
# Bidirectional LSTM processes sequence both forwards and backwards
bilstm = nn.LSTM(
input_size=20,
hidden_size=64,
num_layers=2,
batch_first=True,
dropout=0.3,
bidirectional=True # ← key parameter
)
x = torch.randn(4, 10, 20)
h0 = torch.zeros(4, 4, 64) # num_layers * num_directions = 2 * 2 = 4
c0 = torch.zeros(4, 4, 64)
output, (h_n, c_n) = bilstm(x, (h0, c0))
# output: concatenation of forward and backward hidden states at each step
print(f"Bidirectional output: {output.shape}") # (4, 10, 128) — 64*2 = 128!
print(f"h_n: {h_n.shape}") # (4, 4, 64) — 4 = 2*2
# The first 64 dims = forward direction; last 64 dims = backward direction
forward_out = output[:, :, :64] # (4, 10, 64)
backward_out = output[:, :, 64:] # (4, 10, 64)
Gradient Clipping
While LSTM/GRU solve vanishing gradients, they can still suffer from exploding gradients — when the product of many Jacobians is larger than 1 rather than smaller. Gradient clipping caps the gradient norm at a threshold before the optimizer step.
import torch
import torch.nn as nn
import torch.optim as optim
model = nn.LSTM(20, 64, batch_first=True)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# In the training loop:
optimizer.zero_grad()
# ... forward pass, compute loss ...
# loss.backward()
# Clip gradients: rescale so total grad norm <= max_norm
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# THEN step the optimizer
optimizer.step()
# Rule of thumb: max_norm = 1.0 is standard for most RNN tasks
# Check if clipping is triggering: total_norm > max_norm means clipping activated
Packing Variable-Length Sequences
In a batch, sequences rarely have the same length. We pad shorter sequences with zeros to make them the same size — but we don't want the RNN to waste compute on padding, and we don't want the padding to contaminate the hidden states.
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
import torch
import torch.nn as nn
lstm = nn.LSTM(input_size=20, hidden_size=64, batch_first=True)
# Batch of 3 sequences with lengths 5, 3, 2 (padded to 5)
batch = torch.randn(3, 5, 20) # (batch=3, max_len=5, features=20)
lengths = torch.tensor([5, 3, 2]) # actual lengths
# Pack: tells PyTorch which positions are real vs padding
packed = pack_padded_sequence(batch, lengths, batch_first=True, enforce_sorted=True)
# Run LSTM on packed sequence (skips padding automatically)
packed_output, (h_n, c_n) = lstm(packed)
# Unpack back to padded tensor (for position-level outputs)
output, output_lengths = pad_packed_sequence(packed_output, batch_first=True)
print(f"Output shape: {output.shape}") # (3, 5, 64) — padded back to max_len
The num_layers parameter stacks multiple LSTM layers — the output of layer 1 becomes the input of layer 2. More layers = more representational capacity, but also more prone to overfitting and slower to train. For most NLP tasks: 1–2 layers is enough. The seminal sequence-to-sequence paper (Sutskever et al., 2014) used 4 layers. Modern practice often prefers wider (larger hidden_size) rather than deeper (more layers) for RNNs. Always add dropout between layers to regularize stacked RNNs.
Real-World Spotlight: LSTM Sentiment Analysis on Movie Reviews
Let's build a complete sentiment classifier for IMDb movie reviews using an LSTM. This is a classic "many-to-one" task: process a variable-length sequence of words → output positive or negative.
import torch
import torch.nn as nn
import torch.optim as optim
class SentimentLSTM(nn.Module):
"""
LSTM-based binary sentiment classifier.
Pipeline: raw text -> token ids -> embeddings -> LSTM -> classifier
"""
def __init__(self, vocab_size, embed_dim, hidden_size,
num_layers=2, dropout=0.5):
super().__init__()
# Embedding layer: maps integer token IDs to dense vectors
# vocab_size: number of unique words in vocabulary
# embed_dim: dimensionality of each word embedding
self.embedding = nn.Embedding(
vocab_size, embed_dim, padding_idx=0
)
# LSTM: processes the embedded sequence
self.lstm = nn.LSTM(
input_size=embed_dim,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
bidirectional=True
)
# Classifier head: bidirectional doubles hidden_size
self.classifier = nn.Sequential(
nn.Dropout(dropout),
nn.Linear(hidden_size * 2, 64), # *2 for bidirectional
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(64, 1) # binary: 1 logit
)
def forward(self, x, lengths):
"""
x: (batch, max_seq_len) — token integer indices
lengths: (batch,) — actual sequence lengths
"""
# Embed tokens: (batch, seq_len) -> (batch, seq_len, embed_dim)
embedded = self.embedding(x)
# Pack for efficient computation (skip padding positions)
packed = nn.utils.rnn.pack_padded_sequence(
embedded, lengths.cpu(), batch_first=True, enforce_sorted=False
)
# LSTM forward pass
packed_out, (h_n, c_n) = self.lstm(packed)
# Use the final hidden states from both directions
# h_n shape: (num_layers * 2, batch, hidden_size)
# We want last layer's forward (h_n[-2]) and backward (h_n[-1])
h_forward = h_n[-2, :, :] # (batch, hidden_size)
h_backward = h_n[-1, :, :] # (batch, hidden_size)
h_final = torch.cat([h_forward, h_backward], dim=1) # (batch, hidden*2)
return self.classifier(h_final).squeeze(1) # (batch,)
# Model setup
VOCAB_SIZE = 25_000 # top 25k most frequent words
EMBED_DIM = 128 # word embedding dimension
HIDDEN_SIZE = 256 # LSTM hidden state size
model = SentimentLSTM(VOCAB_SIZE, EMBED_DIM, HIDDEN_SIZE)
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = model.to(device)
# Count parameters
total = sum(p.numel() for p in model.parameters())
print(f"Total parameters: {total:,}")
# Embedding: 25000 * 128 = 3.2M
# BiLSTM: ~4 * (128*256*2 + 256*256*2) ≈ 1.8M
# Classifier: tiny
# Total: ~5M
# Training recipe
criterion = nn.BCEWithLogitsLoss() # combines sigmoid + BCE (more numerically stable)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=2, factor=0.5)
# Gradient clipping is essential for RNNs
MAX_GRAD_NORM = 1.0
# Expected results on IMDb (50k reviews, 25k train / 25k test):
print("\nExpected performance (25k training reviews):")
print(" Vanilla RNN (1 layer): ~82% test accuracy")
print(" 2-layer bidir LSTM (this): ~87% test accuracy")
print(" GRU (equivalent config): ~86% test accuracy")
print(" BERT (transformer, Lesson 51): ~93% test accuracy")
The model achieves ~87% accuracy on a classic NLP benchmark with a relatively small, well-understood architecture. The improvements over a vanilla RNN are clear and come directly from the LSTM's gating mechanisms: the bidirectional LSTM can see both the beginning and end of long reviews when making its decision, and the LSTM's cell state allows it to track sentiment-relevant words ("excellent", "terrible", "disappointing") across dozens of intervening words.
The comparison at the end — 87% (LSTM) vs 93% (BERT) — sets up the next major leap: the Attention mechanism and Transformers (Lessons 50–51). LSTM is still widely used in 2025 for time series, audio, and streaming applications where the full context is not available at once (you can't use Transformers to predict stock prices in real time, because you don't have "future" tokens). But for static text classification, Transformers have largely superseded LSTMs.
✍️ Practice Exercises
- Build a character-level language model: train a single-layer LSTM on a text corpus (e.g., a book from Project Gutenberg). At each step, predict the next character. After 10 epochs, generate 200 characters of text by sampling from the model's output distribution.
- Compare vanilla RNN vs LSTM on a synthetic long-range dependency task: the label is the XOR of the first and last element of a length-50 binary sequence. How many epochs does each model need to reach >90% accuracy? (RNN should fail; LSTM should succeed.)
- Add gradient clipping to the sentiment analysis training loop. Run for 5 epochs without clipping and 5 epochs with
max_norm=1.0. Plot the gradient norms over training batches. How often does clipping activate? - Implement the bidirectional GRU variant of the sentiment classifier (replace
nn.LSTMwithnn.GRU). How many parameters does it save? What is the final test accuracy compared to BiLSTM?
▶ Show Solution (Exercise 2 — Long-Range Dependency Test)
import torch, torch.nn as nn, torch.optim as optim
# Task: predict XOR of first and last element of a length-50 binary sequence
def make_batch(batch_size=64, seq_len=50):
x = torch.randint(0, 2, (batch_size, seq_len, 1)).float()
# Label: XOR of position 0 and position -1
y = (x[:, 0, 0].long() ^ x[:, -1, 0].long()).float()
return x, y
class SimpleRNN(nn.Module):
def __init__(self, model_type='rnn'):
super().__init__()
if model_type == 'rnn':
self.rnn = nn.RNN(1, 32, batch_first=True)
else:
self.rnn = nn.LSTM(1, 32, batch_first=True)
self.fc = nn.Linear(32, 1)
self.model_type = model_type
def forward(self, x):
if self.model_type == 'lstm':
out, (h_n, _) = self.rnn(x)
else:
out, h_n = self.rnn(x)
return self.fc(h_n.squeeze(0)).squeeze(1)
for mtype in ['rnn', 'lstm']:
model = SimpleRNN(mtype)
opt = optim.Adam(model.parameters(), lr=1e-3)
crit = nn.BCEWithLogitsLoss()
for epoch in range(1, 31):
x, y = make_batch(256, 50)
opt.zero_grad()
loss = crit(model(x), y)
loss.backward()
opt.step()
if epoch % 10 == 0:
preds = (torch.sigmoid(model(x)) > 0.5).float()
acc = (preds == y).float().mean()
print(f"{mtype:4s} epoch {epoch:2d}: acc={acc:.3f}")
print()
📚 Primary Source for This Lesson
Hochreiter & Schmidhuber (1997) — "Long Short-Term Memory"
The original LSTM paper, which solved the vanishing-gradient problem in vanilla RNNs with the gating mechanism covered in this lesson. For the simplified GRU architecture, see Cho et al. (2014) "Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation."