04 — Sequence Models

Phase 2 · Months 5–6 | Estimated time: 12–16 hours across 2 weeks

Sequence modeling is the problem of building representations that depend on order. A bag-of-words model treats “dog bites man” and “man bites dog” identically — which is fine until you need to care about the difference. RNNs, LSTMs, and GRUs are the 30-year lineage of solutions to this problem before transformers arrived. They are not obsolete. They are the foundation on which transformer attention makes sense.


1. The RNN: Unrolled in Time

A vanilla RNN maintains a single hidden state vector h_t that is updated at each timestep by combining the previous hidden state with the current input:

h_t = tanh(W_hh · h_{t-1} + W_xh · x_t + b_h)
y_t = W_hy · h_t + b_y

Parameter count: W_hh is (H × H), W_xh is (H × D) where H = hidden size, D = input size. Crucially, these weights are shared across all timesteps — the same transformation is applied at t=1, t=2, …, t=T. This is the RNN’s inductive bias: whatever transformation is useful at one timestep is useful at all timesteps.

Theoretically, this means an RNN can process sequences of arbitrary length. In practice, as you’ll see below, “arbitrary” is doing a lot of optimistic work.


2. BPTT and the Vanishing/Exploding Gradient Problem

Backpropagation Through Time (BPTT) unfolds the RNN across timesteps and computes gradients by the chain rule across that unrolled graph.

The gradient of the loss with respect to the hidden state at time t=1 involves a product of Jacobians across all timesteps from T back to 1:

∂L/∂h_1 = ∂L/∂h_T · (∏_{t=2}^{T} ∂h_t/∂h_{t-1})

Each factor ∂h_t/∂h_{t-1} is approximately W_hh^T · diag(1 - h_{t-1}²) for tanh activations. The critical quantity is the spectral radius of this product — the largest singular value of the chain of Jacobians.

If spectral radius < 1: Gradients shrink exponentially with T. At T=100, you’re multiplying by 0.9^100 0.00003. Early timesteps receive essentially zero gradient. The network can’t learn long-range dependencies.

If spectral radius > 1: Gradients explode exponentially. At T=100, 1.1^100 13,781. Weights diverge; training diverges.

The mathematical root cause: Both problems are consequences of the same structure — a repeated matrix multiplication. The eigenvalues of W_hh either dominate (>1) or disappear (<1) as you raise the matrix to the power of T. There is no stable middle ground at depth.


3. LSTM: Gating the Gradient Highway

The LSTM (Hochreiter & Schmidhuber, 1997) solves the vanishing gradient problem for medium-length sequences with a structural redesign: it maintains a separate cell state c_t that accumulates information additively, not multiplicatively. Additions don’t vanish.

The LSTM has four gates. Each is a learned function of the current input x_t and previous hidden state h_{t-1}.

Gate Equations

Forget gate f_t — decides what fraction of the previous cell state to keep:

f_t = σ(W_f · [h_{t-1}, x_t] + b_f)

Output ∈ (0, 1) per element. Value near 0: forget. Value near 1: remember. This is the gate that decides “this sentence just ended, discard the subject I was tracking.”

Input gate i_t — decides which new information to write into the cell:

i_t = σ(W_i · [h_{t-1}, x_t] + b_i)

Controls the magnitude of the update. Gate near 0: ignore the new information entirely.

Candidate cell g_t — the content of the proposed update:

g_t = tanh(W_g · [h_{t-1}, x_t] + b_g)

The tanh bounds values in (−1, 1). This is what the input gate modulates.

Output gate o_t — decides what part of the cell state to expose as the hidden state:

o_t = σ(W_o · [h_{t-1}, x_t] + b_o)

The cell state stores information; the hidden state is the filtered output.

State Update Equations

c_t = f_t ⊙ c_{t-1}  +  i_t ⊙ g_t
h_t = o_t ⊙ tanh(c_t)

where denotes element-wise multiplication.

Why c_t solves vanishing gradients: The cell state update is additivec_t = f_t c_{t-1} + i_t g_t. The gradient flowing back through c_t is:

∂c_t/∂c_{t-1} = f_t

This is the forget gate value — which is learned. Contrast this with the RNN’s W_hh^T · diag(tanh'(·)), which is a fixed matrix multiplication with no learned mechanism to prevent decay. The LSTM can learn to set f_t 1 (keep everything) over long stretches, giving gradients a near-lossless pathway. The cell state is, architecturally, a gradient highway.


4. GRU: The Simplified Alternative

The GRU (Cho et al., 2014) collapses the LSTM’s four gates into two and merges the cell state and hidden state into a single vector.

z_t = σ(W_z · [h_{t-1}, x_t])       # update gate (like LSTM's forget + input combined)
r_t = σ(W_r · [h_{t-1}, x_t])       # reset gate
h̃_t = tanh(W · [r_t ⊙ h_{t-1}, x_t])  # candidate hidden state
h_t = (1 - z_t) ⊙ h_{t-1}  +  z_t ⊙ h̃_t

Update gate z_t controls the interpolation between previous and new state: when z_t 0, keep the old state; when z_t 1, replace it.

Reset gate r_t controls how much of the previous hidden state influences the candidate.

Criterion

LSTM

GRU

Parameters

~4× hidden size²

~3× hidden size² (25% fewer)

Training speed

Slower

~20–30% faster

Performance

Marginally better on long sequences

Competitive; often equal on standard benchmarks

Memory

Higher

Lower

Use when

You have long sequences (>300 steps), asymmetric forget/output behavior matters

Standard sequence tasks, speed matters, lower memory budget

Practical rule: Start with GRU. Switch to LSTM if the task has evidence of very long-range dependencies and GRU plateaus first.


5. Where RNNs/LSTMs Are Still Used in 2025

Transformers did not uniformly replace recurrent models. The replacement was domain-specific, and several high-value use cases remain firmly in LSTM/GRU territory.

The common thread in LSTM survival cases: either the input is inherently streaming (no full-sequence attention is possible), or the device budget rules out transformer quadratic attention costs.

Domain

Model / Context

Why Not Transformer

Time-series forecasting

N-BEATS, PatchTST, LSTNet

Temporal locality; short context windows where LSTM is competitive and faster

Streaming audio / speech

Online ASR systems, keyword spotting

Must process token-by-token with bounded latency; full-sequence attention is incompatible

Edge / embedded devices

TensorFlow Lite LSTM, CoreML

Transformers are parameter-heavy; LSTM at H=256 is deployable on MCUs

RL environments

LSTM policy networks in PPO/DRQN

Partial observability; hidden state as working memory; transformers add too much overhead

Online anomaly detection

Streaming LSTM autoencoders

Real-time constraint; can’t wait for full sequence before making a decision


6. Where Transformers Won

For NLP tasks with sequences longer than ~64 tokens, transformers strictly dominate. The attention mechanism’s O(n²) cost is a limitation, but the parallel training and global receptive field are decisive.

  • Language modeling (GPT-*): attention sees all previous tokens simultaneously; LSTM processes them serially and forgets early ones

  • Machine translation (>~50 token sequences): alignment across long-range dependencies (subject–verb agreement across clauses) requires global attention

  • Named entity recognition, classification: BERT-style bidirectional transformers with fine-tuning

  • Any task where you have a full sequence at inference time and model size is not a constraint


7. Code: LSTM Character-Level Language Model

This trains on a text corpus and generates new text. The generated output demonstrates whether the model has actually learned statistical structure. A purely random character model would produce something like xt#qpw!. A trained LSTM produces something resembling the source domain.

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import random

# ── Data Preparation ────────────────────────────────────────────────────────
# Use a small Shakespeare excerpt. Download or replace with any UTF-8 text.
# wget https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt

with open("input.txt", "r") as f:
    text = f.read()

print(f"Corpus length: {len(text):,} characters")

chars    = sorted(set(text))
vocab_sz = len(chars)
char2idx = {c: i for i, c in enumerate(chars)}
idx2char = {i: c for c, i in char2idx.items()}

print(f"Vocabulary size: {vocab_sz}")

def encode(s): return [char2idx[c] for c in s]
def decode(lst): return "".join(idx2char[i] for i in lst)

data     = torch.tensor(encode(text), dtype=torch.long)
n        = len(data)
train_d  = data[:int(0.9 * n)]
val_d    = data[int(0.9 * n):]

# ── Dataset: Sliding Window Sequences ─────────────────────────────────────
SEQ_LEN    = 100
BATCH_SIZE = 128

def get_batch(split):
    d   = train_d if split == "train" else val_d
    ix  = torch.randint(len(d) - SEQ_LEN, (BATCH_SIZE,))
    x   = torch.stack([d[i    : i + SEQ_LEN    ] for i in ix])
    y   = torch.stack([d[i + 1: i + SEQ_LEN + 1] for i in ix])
    return x, y

# ── Model ──────────────────────────────────────────────────────────────────
class CharLSTM(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size, num_layers, dropout=0.3):
        super().__init__()
        self.embed       = nn.Embedding(vocab_size, embed_dim)
        self.lstm        = nn.LSTM(embed_dim, hidden_size, num_layers,
                                   batch_first=True, dropout=dropout)
        self.dropout     = nn.Dropout(dropout)
        self.fc          = nn.Linear(hidden_size, vocab_size)
        self.hidden_size = hidden_size
        self.num_layers  = num_layers

    def forward(self, x, hidden=None):
        embed  = self.dropout(self.embed(x))              # (B, T, E)
        out, hidden = self.lstm(embed, hidden)             # out: (B, T, H)
        logits = self.fc(self.dropout(out))                # (B, T, V)
        return logits, hidden

    def init_hidden(self, batch_size, device):
        h = torch.zeros(self.num_layers, batch_size, self.hidden_size).to(device)
        c = torch.zeros(self.num_layers, batch_size, self.hidden_size).to(device)
        return (h, c)


DEVICE     = torch.device("cuda" if torch.cuda.is_available() else "cpu")
EMBED_DIM  = 128
HIDDEN     = 512
N_LAYERS   = 2
EPOCHS     = 20
LR         = 3e-3

model     = CharLSTM(vocab_sz, EMBED_DIM, HIDDEN, N_LAYERS).to(DEVICE)
optimizer = optim.Adam(model.parameters(), lr=LR)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)
criterion = nn.CrossEntropyLoss()

print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")

# ── Training ────────────────────────────────────────────────────────────────
STEPS_PER_EPOCH = 200

for epoch in range(EPOCHS):
    model.train()
    total_loss = 0.0

    for step in range(STEPS_PER_EPOCH):
        x, y    = get_batch("train")
        x, y    = x.to(DEVICE), y.to(DEVICE)
        hidden  = model.init_hidden(BATCH_SIZE, DEVICE)

        logits, _ = model(x, hidden)                             # (B, T, V)
        loss      = criterion(logits.view(-1, vocab_sz), y.view(-1))

        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)  # gradient clipping
        optimizer.step()

        total_loss += loss.item()

    scheduler.step()

    # Perplexity = exp(cross-entropy loss)
    avg_loss   = total_loss / STEPS_PER_EPOCH
    perplexity = np.exp(avg_loss)
    print(f"Epoch {epoch+1:02d} | loss={avg_loss:.4f} | perplexity={perplexity:.1f}")

# ── Text Generation ─────────────────────────────────────────────────────────
def generate(model, seed_text, length=500, temperature=0.8):
    """
    temperature < 1.0 → more conservative (picks high-prob chars)
    temperature > 1.0 → more creative / random
    """
    model.eval()
    chars_in = encode(seed_text)
    hidden   = model.init_hidden(1, DEVICE)

    # Warm up hidden state on seed
    with torch.no_grad():
        for ch in chars_in[:-1]:
            x = torch.tensor([[ch]], dtype=torch.long).to(DEVICE)
            _, hidden = model(x, hidden)

    result = list(seed_text)
    x      = torch.tensor([[chars_in[-1]]], dtype=torch.long).to(DEVICE)

    with torch.no_grad():
        for _ in range(length):
            logits, hidden = model(x, hidden)         # (1, 1, V)
            logits  = logits[0, 0] / temperature
            probs   = torch.softmax(logits, dim=-1)
            next_ch = torch.multinomial(probs, 1).item()
            result.append(idx2char[next_ch])
            x = torch.tensor([[next_ch]], dtype=torch.long).to(DEVICE)

    return "".join(result)


print("\n=== Generated Text (temperature=0.8) ===")
sample = generate(model, seed_text="ROMEO:\n", length=400, temperature=0.8)
print(sample)

# Expected output after 20 epochs on tinyshakespeare (~1M chars):
# Perplexity starts ~60–65 (epoch 1), converges to ~12–18 (epoch 20)
# Bigram baseline perplexity: ~25–30 → LSTM beats it by epoch 5
#
# Sample output (trained):
# "ROMEO:
# Why, then, the world's mine oyster, which I with sword will open.
# JULIET: What satisfaction canst thou have to-night?
# ROMEO: The exchange of thy love's faithful vow for mine."
# (Not exact, but structure, capitalization, and dialogue format will emerge)

Perplexity as a metric: Perplexity = exp(cross-entropy loss). A value of 1.0 means perfect prediction. A uniform distribution over 65 characters gives perplexity = 65. An LSTM trained on Shakespeare should converge to ~12–18, meaning the model is as uncertain as if it had ~15 equally likely choices at each step — far better than random.


What Most People Get Wrong

LSTMs don’t “solve” the vanishing gradient problem. They mitigate it for medium-length sequences.

The cell state’s additive update c_t = f_t c_{t-1} + i_t g_t is not a guaranteed gradient highway. It is a learned highway. The forget gate f_t is a learned sigmoid. Nothing forces the network to keep it near 1.0 for very long sequences — in practice, LSTMs still struggle when sequence length exceeds ~300–500 steps on tasks that require non-trivial long-range reasoning.

The Transformer’s self-attention has O(1) path length between any two tokens — a direct connection, not a chain of recurrent updates. That is why transformers structurally dominate LSTMs on tasks requiring long-range dependencies. The LSTM mitigated the problem with gates. The transformer eliminated it with attention. Understanding this distinction is what separates an engineer who knows when to reach for which tool.


Return to README.md · Next: 05_training_dynamics.md