Python Lesson 55 of 71

Advanced Architectures: CNN, RNN/LSTM, Transformers & Reinforcement Learning Basics

You have built a neural network from scratch — a pile of weighted sums, a nonlinearity, and backprop — in Deep Learning Basics: Neural Network Intuition, Backprop & Training, and you have handed that job to a framework in PyTorch & Keras: Your First Models. What you have not yet seen is why deep learning has a dozen named architectures — CNN, RNN, LSTM, GRU, Transformer, autoencoder, GAN, diffusion — instead of one. This lesson is the map. It is the difference between “I can train a network” and “I know which network the problem is asking for.”

Here is the single idea that organizes the entire zoo, and if you take one sentence from this lesson, take this one: an architecture is an inductive bias matched to the structure of the data. A plain multilayer perceptron (MLP) assumes nothing about its inputs — every input connects to every neuron, order and locality mean nothing. That is fine for a flat row of features, and hopeless for a 224×224 image (50,000 inputs, every pixel wired to every neuron) or a sentence (which can be 5 words or 500). Each specialized architecture bakes in an assumption — nearby pixels are related (CNN), order carries meaning and the past informs the present (RNN), any element may depend on any other, near or far (Transformer), you learn by acting and being rewarded (RL) — and that assumption is exactly what lets it learn efficiently from the data’s real shape. Match the bias to the structure and the network learns; mismatch it and you are fighting the architecture.

We will build the load-bearing pieces in code you can run. A vanilla RNN forward pass, hidden state evolving step by step (and checked against torch.nn.RNNCell so you know it is a real RNN). A self-attention computation by hand — Query, Key, Value, the scaled dot-product, the softmax — with the attention-weight matrix printed so you can see each row sum to 1 (checked against torch.nn.functional.scaled_dot_product_attention). A demonstration that attention is permutation-invariant, which is why positional encoding is not optional. And a tabular Q-learning agent that starts knowing nothing and learns the optimal 6-step path across a gridworld, its policy printed as arrows so you watch it improve. The things too heavy to train honestly on a laptop — an LLM, a diffusion model, a deep-RL game agent — are explained conceptually and flagged as conceptual; there are no fabricated training curves in this lesson, only numbers that actually came out of the interpreter.

Set up the environment. Use a virtual environment; never install into the system Python. PyTorch’s CPU build is all we need:

python3 -m venv .venv
source .venv/bin/activate                 # Windows: .venv\Scripts\activate
pip install numpy
pip install torch --index-url https://download.pytorch.org/whl/cpu
python -c "import numpy, torch; print('numpy', numpy.__version__, '· torch', torch.__version__)"
# => numpy 2.5.1 · torch 2.13.0     (any recent versions are fine)

This lesson assumes numpy fluency — @ for matrix multiply, .T, broadcasting, axis= — from NumPy: Arrays, Broadcasting & Vectorization, because every architecture here is those operations arranged differently. It builds on the model-family thinking in ML Fundamentals: Supervised vs Unsupervised: choosing an architecture is the same discipline as choosing a model — match the tool to the data, start simple, escalate only when the data’s structure demands it.


Why this matters

A beginner’s instinct, after building one network, is to reach for “a neural network” as if it were a single thing you make bigger or smaller. It is not. The moment you leave flat tabular data, the shape of your input dictates the architecture, and using the wrong one wastes compute, data, and weeks. Feed a raw image to a plain MLP and you throw away the one fact that makes vision tractable — that a pixel is related to its neighbours, not to a pixel across the frame — and you need orders of magnitude more parameters and data to relearn it from scratch. Feed a variable-length sentence to a fixed-width MLP and you cannot even represent the input. The architecture is not decoration; it is how you tell the network what kind of world its data lives in.

The second reason this matters is that the field moves through architectures, and reading any modern paper, model card, or job description requires knowing the vocabulary and, more importantly, the why. “We used a Transformer” is a statement about an inductive bias (long-range, parallelizable, order-injected) and its costs (O(n²) attention, data-hungry). “We fine-tuned an LLM” presumes you know what pretraining bought and what it did not (a fluent text predictor, not a database or a reasoner). “We trained an RL policy” signals a completely different learning paradigm — no labeled dataset at all, learning from a reward signal by trial and error — with its own notorious instabilities. This lesson gives you that vocabulary grounded in code, not slogans.

The third reason is honesty. Deep learning is surrounded by more hype than any topic in computing, and the antidote is the same one that demystified backprop: see the arithmetic. Self-attention sounds mystical until you compute a 3×3 attention matrix by hand and watch each row sum to 1 — then it is just a softmax over dot products. Reinforcement learning sounds like magic until you watch a Q-table fill with discounted rewards via a one-line Bellman update. The architectures that power ChatGPT and AlphaGo are built from pieces you can run in numpy on a laptop; scale and engineering separate the toy from the product, but the idea is small enough to hold in your head, and this lesson hands you each one.

Here is the territory, one row per family, each of which we unpack below:

Family Inductive bias (what it assumes) Data it fits Signature mechanism
MLP (feed-forward) nothing — all inputs interchangeable flat feature vectors (tabular) dense weighted sums
CNN nearby inputs are related; features are translation-invariant grids: images, spectrograms convolution (sliding filters)
RNN / LSTM / GRU order matters; the past informs the present sequences: text, time series, audio recurrent hidden state
Transformer any element may depend on any other, near or far sequences + sets, at scale self-attention
Autoencoder / VAE / GAN / diffusion data lies on a low-dimensional manifold you can model and sample generation, compression reconstruction / adversarial / denoising
Reinforcement learning learn by acting and receiving reward agent in an environment value/policy + reward signal

The architecture families: a quick recap

Start with the plainest network and see exactly where it runs out of road; the specialized architectures are each a fix for one specific failure of the MLP.

The MLP (feed-forward network) is the one you built from scratch: a = f(X @ W + b), layers stacked, every input connected to every neuron in the next layer (“fully connected” or “dense”). It assumes nothing about the relationship between inputs — swap two input columns and, with retraining, nothing changes. That total absence of assumptions is its strength on tabular data (where a column really is just a column) and its fatal weakness everywhere else. Two concrete failures drive the whole rest of this lesson:

  1. It has no notion of locality or space. Flatten a 28×28 digit into 784 numbers and the MLP has no idea that pixel 0 sits next to pixel 1 but far from pixel 500. It must learn every spatial relationship from data, from scratch, with a separate weight for every pixel-neuron pair — millions of parameters for even a small image. The CNN fixes this by sharing a small set of weights (a filter) across all positions and only looking at local neighbourhoods, encoding “nearby pixels relate, and a cat is a cat wherever it appears.” That is the whole content of CNNs: Image Classification & Object Detection, so we treat CNNs briefly here and spend our runnable budget on the sequence and RL families.

  2. It has a fixed input width and no notion of order. An MLP with 10 input neurons takes exactly 10 numbers, always. A sentence is 5 words or 50; a stock’s history is however many days you have. And even at fixed width, the MLP treats input position 3 and position 7 as unrelated slots — it has no built-in sense that a sequence flows. The RNN and the Transformer are two different fixes for this failure, and the tension between them is the spine of modern NLP.

The progression is historical and logical at once, each architecture inheriting the previous one’s wins and fixing its worst limitation:

Step Architecture Fixes the previous one’s… At the cost of…
1 MLP (baseline) — learns any fixed-size mapping no locality, fixed width, no order
2 CNN spatial blindness — shares filters over a grid assumes grid structure (bad for sequences)
3 RNN fixed width + order — processes any-length sequences step by step slow (sequential), forgets long-range
4 LSTM / GRU RNN’s forgetting — gates preserve long-range memory still sequential, still limited range
5 Transformer RNN’s sequentiality + range — attends to all positions in parallel O(n²) attention cost, needs positional encoding

Since a CNN sits in this lesson’s title but earns a full lesson of its own, here is the mechanism in miniature so the map is complete. A convolution slides a small grid of weights (a filter or kernel, say 3×3) across the image, computing a dot product at each position; the output is a feature map marking where that filter’s pattern occurs. Two ideas carry the whole architecture. Weight sharing — the same filter is applied at every position — means an edge detector learned in one corner works in all corners (translation invariance) and costs a handful of weights instead of the millions a dense layer would need. Local receptive fields — each output sees only a small neighbourhood — bake in the assumption “nearby pixels relate.” Stack convolutions and the hierarchy emerges on its own: early layers learn edges, middle layers learn shapes, deep layers learn objects. Pooling layers (e.g. 2×2 max) downsample between convolutions, shrinking the map and adding a little more tolerance to small shifts. That inductive bias — locality plus translation invariance — is exactly why a CNN thrashes a plain MLP on images while being the wrong tool for a sequence, where position is not a spatial grid.

CNN piece What it does Why it matters
Filter / kernel small weight grid slid over the input detects a local pattern (edge, texture)
Weight sharing same filter applied at every position translation invariance; few parameters
Feature map one filter’s output over the whole input shows where that pattern occurs
Pooling downsample, e.g. 2×2 max shrink the map; tolerate small shifts
Depth stacked conv layers edges → shapes → objects (learned hierarchy)

The diagram below is the map for the whole lesson: the data’s structure on the left picks the matched architecture, with self-attention as the centrepiece and the “parallel + long-range” advantage that made Transformers win marked explicitly, then the scale-up into LLMs and its honest caveat. Read it left to right; each numbered badge is a decision or gotcha we prove in code later.

Architecture map reading left to right. On the left, three data structures — a 2-D grid or image, a sequence such as text or a time series, and an agent in an environment with state and reward — each feed into a matched architecture: the grid into a CNN with convolutional filters, the sequence into an RNN or LSTM carrying a hidden state over time, and the agent into reinforcement learning with a value table and an explore-versus-exploit tradeoff. The centre of the map is self-attention: Query, Key and Value vectors where each token attends to every other token, producing an n-by-n attention-weight matrix whose rows are softmax distributions summing to one, verified in numpy and PyTorch. To its right, the reasons Transformers won are marked — parallel processing with no time-step recurrence, and long-range reach where any token connects to any other in one hop — which scales up into large language models pretrained at massive scale that show in-context learning and emergent skills, with a red caution that an LLM is not a database or a reasoner and hallucinates. Six numbered badges mark the RNN vanishing-gradient problem, the reinforcement-learning reward loop, the self-attention core, the permutation-invariance and quadratic-memory gotchas, the parallelism advantage, and the LLM caveat.

Everything from here fills in that picture: the sequence models (RNN, LSTM), the Transformer and its self-attention centrepiece, the generative families, and reinforcement learning. Where a piece is small enough to run, we run it.


Sequence models: why an MLP or CNN struggles with order

A sequence is ordered data of variable length where earlier elements inform later ones: the words of a sentence, the daily prices of a stock, the samples of an audio waveform, the amino acids of a protein. Two properties make sequences hostile to the MLP, and it is worth naming them precisely because the RNN and Transformer are engineered around exactly these two facts.

Variable length. A sentence can be 3 words or 300. An MLP has a fixed number of input neurons, so it cannot, in principle, accept “a sentence” — only “exactly N numbers.” You can hack around it (pad or truncate to a fixed length, or average the word vectors into one fixed vector — a “bag of words”), but padding wastes computation and averaging destroys the order entirely, which is the second problem.

Order carries meaning. “The dog bit the man” and “the man bit the dog” contain identical words in identical counts; only the order distinguishes a normal Tuesday from a news story. A bag-of-words model sees them as identical. An MLP fed the words in fixed slots sees position 1 and position 4 as unrelated inputs with independent weights — it has no built-in machinery that says “this is a sequence flowing left to right, and what came before conditions what comes now.” It can learn some of this given enough data and slots, but it is fighting its own architecture, which assumes no such structure.

A CNN handles local order well — a 1-D convolution over a sequence captures short patterns (“New York”, “not good”) the way a 2-D conv captures edges — and 1-D CNNs are genuinely used for text and time series. But a convolution’s receptive field is local by design: a filter of width 5 sees 5 neighbouring elements. To connect word 1 to word 100 you must stack many conv layers to grow the receptive field, and even then long-range dependencies are indirect. The CNN’s inductive bias (“nearby things relate”) is exactly wrong for “the pronoun in sentence 10 refers to the name in sentence 1.”

Approach to sequences Variable length? Preserves order? Long-range links? Verdict
MLP, padded/truncated forced to fixed N weakly (position slots) poorly wastes compute, brittle
MLP, bag-of-words yes (averaged) no — order destroyed no loses the point of a sequence
1-D CNN yes (sliding) locally only via deep stacks good for local patterns, weak long-range
RNN / LSTM yes (any length) yes (built-in) limited (fading memory) the classic sequence tool
Transformer yes (up to a max) via positional encoding yes (all-pairs) the modern default

The RNN was the first architecture built specifically for the two properties above, so we build one next.


RNNs: a hidden state carried across time

The recurrent neural network’s idea is beautifully simple: process the sequence one element at a time, and carry a running summary — the hidden state — from each step to the next. At every time step the RNN combines the current input with the hidden state left over from the previous step, producing a new hidden state. That hidden state is the network’s memory: a fixed-size vector that, in principle, summarizes everything seen so far. The same weights are reused at every step, which is what lets a single small network process a sequence of any length — the answer to the variable-length problem.

Formally, for input x_t at step t and previous hidden state h_{t-1}:

$$h_t = \tanh(x_t W_{xh} + h_{t-1} W_{hh} + b_h)$$

W_xh maps the current input into hidden space; W_hh — the recurrent weight — maps the previous hidden state forward; tanh is the nonlinearity. Read it as: the new memory is a squashed blend of what I just saw and what I remembered. Let us build exactly this in numpy and watch the hidden state evolve as it consumes a short 4-step sequence.

import numpy as np

# A vanilla RNN cell processing a short sequence, hidden state carried across time.
# h_t = tanh(x_t @ W_xh + h_{t-1} @ W_hh + b_h)
rng = np.random.default_rng(0)

T, in_dim, hidden = 4, 2, 3                    # 4 time steps, 2 input features, 3 hidden units
W_xh = rng.normal(0, 0.5, (in_dim, hidden))    # input -> hidden
W_hh = rng.normal(0, 0.5, (hidden, hidden))    # hidden -> hidden (the recurrence)
b_h  = np.zeros(hidden)

xs = np.array([[1.0, 0.0],                      # a toy 4-step input sequence
               [0.5, 0.5],                      #   (imagine 4 words / 4 timesteps)
               [0.0, 1.0],
               [1.0, 1.0]])

h = np.zeros(hidden)                            # h_0: the initial memory is empty
print("h_0 (start):", h.round(4))
for t in range(T):
    h = np.tanh(xs[t] @ W_xh + h @ W_hh + b_h)  # SAME weights reused every step
    print(f"step {t+1}: x={xs[t]}  ->  h_{t+1} = {h.round(4)}")

print("\nfinal hidden state (summary of whole sequence):", h.round(4))
print("W_xh shape:", W_xh.shape, " W_hh shape:", W_hh.shape, " (reused at every step)")
h_0 (start): [0. 0. 0.]
step 1: x=[1. 0.]  ->  h_1 = [ 0.0628 -0.066   0.3097]
step 2: x=[0.5 0.5]  ->  h_2 = [-0.2162 -0.1494  0.0341]
step 3: x=[0. 1.]  ->  h_3 = [-0.0336 -0.3162  0.2284]
step 4: x=[1. 1.]  ->  h_4 = [ 0.0279 -0.2695  0.3487]
W_xh shape: (2, 3)  W_hh shape: (3, 3)  (reused at every step)

Watch the hidden state h change at every step — it starts empty (h_0 = [0, 0, 0]), and each new input rotates and reshapes it. Step 4’s hidden state [0.0279, -0.2695, 0.3487] depends, through the chain of W_hh multiplications, on all four inputs, not just the last — that is the memory. For a classification task (sentiment of a sentence) you would feed this final hidden state to an output layer; for a translation or generation task you would emit an output at every step. The crucial structural facts: the same W_xh and W_hh are reused at every step (so any length works), and information flows forward only through that one fixed-size h bottleneck.

Is this a real RNN, or did we just make up an equation? Cross-check it against PyTorch’s built-in nn.RNNCell, copying our exact weights into it (PyTorch computes W_ih @ x where we compute x @ W_xh, so we transpose):

import torch, torch.nn as nn
cell = nn.RNNCell(in_dim, hidden, bias=True).double()
with torch.no_grad():
    cell.weight_ih.copy_(torch.tensor(W_xh.T))     # torch uses W_ih @ x -> transpose ours
    cell.weight_hh.copy_(torch.tensor(W_hh.T))
    cell.bias_ih.zero_(); cell.bias_hh.zero_()
ht = torch.zeros(1, hidden, dtype=torch.double)
for t in range(T):
    ht = cell(torch.tensor(xs[t:t+1]), ht)
print("numpy final h:", h.round(4))
print("torch final h:", ht.detach().numpy().round(4).ravel())
print("match?", np.allclose(h, ht.detach().numpy().ravel(), atol=1e-6))
# => numpy final h: [ 0.0279 -0.2695  0.3487]
# => torch final h: [ 0.0279 -0.2695  0.3487]
# => match? True

They agree to machine precision. Our four lines of numpy are a vanilla RNN — the framework’s version is the same arithmetic with autograd and a GPU kernel attached (the convenience you met in PyTorch & Keras: Your First Models). Here is the anatomy in one table:

Symbol Meaning Shape (our net) Role
x_t input at step t (2,) current element of the sequence
h_t hidden state after step t (3,) the running memory / summary
W_xh input→hidden weights (2, 3) how the current input enters memory
W_hh hidden→hidden (recurrent) weights (3, 3) how the past propagates forward
b_h hidden bias (3,) baseline offset
tanh activation squashes memory into (−1, 1)

RNNs come in a few standard shapes depending on how inputs and outputs line up, and the vocabulary is worth knowing because it names most sequence tasks:

Shape Inputs → outputs Example task
one-to-many one input → a sequence image → caption
many-to-one a sequence → one output sentence → sentiment label
many-to-many (aligned) sequence → sequence, same length per-word part-of-speech tagging
many-to-many (seq2seq) sequence → sequence, different length translation (encoder + decoder)

The vanilla RNN is elegant and it works on short sequences — but it has a crippling flaw on long ones, and that flaw is the reason LSTMs, and ultimately Transformers, exist.


The vanishing and exploding gradient problem

Training an RNN means backpropagating the error not just through layers but through time — unrolling the recurrence across every step and applying the chain rule from the last step back to the first. This is backpropagation through time (BPTT), and it has a structural pathology. To get the gradient at step 1 from an error at step 50, you multiply by the recurrent Jacobian (roughly W_hh times tanh') once per step you travel back — about 50 times. Repeated multiplication by the same matrix is a recipe for exponential behaviour: if that matrix tends to shrink vectors, the gradient vanishes toward zero; if it tends to grow them, the gradient explodes toward infinity. Either way, the early steps stop learning correctly, and the RNN cannot connect distant events.

You do not have to take this on faith — the exponential is easy to exhibit. The key quantity is the recurrent weight’s spectral radius (its largest eigenvalue magnitude): below 1, signals shrink; above 1, they grow. Push a unit gradient back through 50 steps for three different weight scales:

import numpy as np

def signal_decay(scale, T=50):
    rng = np.random.default_rng(0)
    W = rng.normal(0, scale, (4, 4))
    g = np.ones(4)                       # a unit gradient entering at the last step
    for _ in range(T):
        g = 0.9 * (W.T @ g)              # 0.9 stands in for a typical tanh' in the active region
    return np.linalg.norm(g)

for scale in (0.3, 0.6, 1.2):
    rng = np.random.default_rng(0)
    W = rng.normal(0, scale, (4, 4))
    radius = max(abs(np.linalg.eigvals(W)))
    print(f"W scale={scale}: spectral radius={radius:.3f}  ->  |grad| after 50 steps = {signal_decay(scale):.3e}")
W scale=0.3: spectral radius=0.536  ->  |grad| after 50 steps = 2.842e-16
W scale=0.6: spectral radius=1.072  ->  |grad| after 50 steps = 3.200e-01
W scale=1.2: spectral radius=2.145  ->  |grad| after 50 steps = 3.603e+14

Three regimes, three fates for the gradient reaching the first time step:

Recurrent weight scale Spectral radius Gradient after 50 steps What happens
small (0.3) 0.536 (< 1) 2.8×10⁻¹⁶ vanishes — early steps get ~zero signal, cannot learn long-range
medium (0.6) 1.072 (≈ 1) 3.2×10⁻¹ roughly stable — the narrow, hard-to-hit sweet spot
large (1.2) 2.145 (> 1) 3.6×10¹⁴ explodes — gradients overflow, weights go to nan

The vanishing case (~10⁻¹⁶) means an error at word 50 sends essentially no corrective signal back to word 1 — so a vanilla RNN struggles to learn that a sentence’s subject (word 1) governs its verb (word 40). The exploding case (~10¹⁴) blows the weights up and nans the loss. The stable middle is a razor’s edge that ordinary training does not reliably sit on. Exploding gradients have a cheap patch — gradient clipping, capping the gradient’s norm — but vanishing gradients are structural and demanded a new cell design. That design is the LSTM.


LSTM and GRU: gates that let memory and gradients flow

The Long Short-Term Memory cell (LSTM, 1997) fixes vanishing gradients with one structural change: alongside the hidden state it maintains a separate cell state C_t — a memory conveyor belt that runs straight down the sequence with only minor, gated linear interactions. Because information can travel along the cell state with an addition rather than a repeated matrix multiply, the gradient can flow across many steps without vanishing. Small neural gates (each a sigmoid producing values in 0–1, acting as soft on/off valves) decide what to erase, what to write, and what to read at each step. This lesson does not build an LSTM from scratch — the gating arithmetic is fiddly and the point is conceptual — so treat the following as the map, not a runnable derivation.

Gate Question it answers Mechanism
Forget gate what to erase from memory? sigmoid over [h_{t-1}, x_t], multiplies the old cell state
Input gate what new info to write? sigmoid decides how much; a tanh proposes what
Cell state update the memory conveyor belt C_t = forget·C_{t-1} + input·candidate (mostly addition!)
Output gate what to read out as h_t? sigmoid gates a tanh of the cell state

The crucial line is the cell-state update: C_t = f_t · C_{t-1} + i_t · \tilde{C}_t. When the forget gate f_t is near 1 and the input gate near 0, the cell state passes through unchanged — memory persists indefinitely, and the gradient with it, because the derivative of that path is just f_t (near 1) rather than a shrinking product of weights. That is the whole trick: an additive, gated highway for memory instead of a multiplicative bottleneck.

The Gated Recurrent Unit (GRU, 2014) is a streamlined LSTM: it merges the cell and hidden state and uses two gates (reset, update) instead of three. It has fewer parameters, trains a little faster, and performs comparably on most tasks — a reasonable default when you want recurrence with less machinery.

Cell States Gates Vs. vanilla RNN
Vanilla RNN hidden h none baseline; vanishing gradients kill long-range
LSTM hidden h + cell C forget, input, output (3) additive memory highway → learns long-range
GRU hidden h only reset, update (2) fewer params, ~same performance, faster

For roughly two decades, LSTMs were the state of the art for sequences — machine translation, speech recognition, text generation all ran on them, and they remain a fine choice for modest-length time series today. Their honest use cases:

Task Why an LSTM fits Modern reality
Time-series forecasting modest-length, order-critical, limited data still competitive vs. Transformers
Speech recognition long audio sequences, streaming largely moved to Transformers
Text generation / translation order + memory replaced by Transformers
Sensor / IoT streams online, low-latency, small models LSTM/GRU still practical

But LSTMs inherit one flaw they cannot fix: they are inherently sequential. To compute step 50 you must first compute steps 1 through 49, in order — the recurrence forbids parallelism within a sequence. On modern hardware (GPUs, which are massively parallel) that is a catastrophe for training speed on long sequences and huge datasets. And their long-range memory, though vastly better than a vanilla RNN’s, still fades over hundreds of steps. In 2017 a new architecture removed the recurrence entirely, connected every position to every other directly, and processed the whole sequence in parallel. It won so decisively that it now underlies essentially all of modern NLP and much of vision. It is the Transformer.


Transformers: self-attention from scratch

The Transformer’s core idea is self-attention: instead of passing information step by step through a hidden state, let every element look directly at every other element in a single operation, and learn how much each should attend to each. “The animal didn’t cross the street because it was tired” — to resolve “it,” the model needs word “it” to attend to “animal,” seven words back, directly, not through a chain of fading hidden states. Self-attention makes that a one-hop connection, and it does so for all pairs at once, in parallel.

The mechanism gives every token three learned vectors, and the fruit-market analogy is the standard one: each token issues a Query (what am I looking for?), advertises a Key (what do I offer?), and holds a Value (the content I’ll pass on if attended to). A token’s output is a weighted average of all tokens’ Values, where the weights come from how well its Query matches each Key.

Vector Question it answers How it’s made
Query (Q) “what am I looking for?” X @ W_q
Key (K) “what do I contain / offer?” X @ W_k
Value (V) “what will I contribute if attended to?” X @ W_v

The full operation is scaled dot-product attention, four steps: (1) score every Query against every Key with a dot product; (2) scale by 1/√d_k to keep the scores from getting too large; (3) softmax each row into weights that sum to 1; (4) use those weights to average the Values.

$$\text{Attention}(Q,K,V) = \text{softmax}!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V$$

Let us compute it by hand on three tokens (imagine the words “the,” “cat,” “sat”), each a 4-dimensional embedding, and print the attention-weight matrix so you can see the structure:

import numpy as np
rng = np.random.default_rng(42)
d_model = d_k = 4

X = np.array([[1.0, 0.0, 1.0, 0.0],           # 3 token embeddings: "the", "cat", "sat"
              [0.0, 2.0, 0.0, 1.0],
              [1.0, 1.0, 0.0, 0.0]])

W_q = rng.normal(0, 0.5, (d_model, d_k))
W_k = rng.normal(0, 0.5, (d_model, d_k))
W_v = rng.normal(0, 0.5, (d_model, d_k))

Q = X @ W_q          # (3,4)  what each token is "looking for"
K = X @ W_k          # (3,4)  what each token "offers"
V = X @ W_v          # (3,4)  the content each token passes on

def softmax(z):
    z = z - z.max(axis=-1, keepdims=True)     # subtract max for numerical stability
    e = np.exp(z)
    return e / e.sum(axis=-1, keepdims=True)

scores = Q @ K.T / np.sqrt(d_k)               # (3,3) scaled similarity
attn   = softmax(scores)                       # (3,3) each ROW is a probability distribution
out    = attn @ V                              # (3,4) weighted blend of all Values

np.set_printoptions(precision=4, suppress=True)
print("attention weight matrix (rows = queries, cols = keys):")
print(attn)
print("\neach row sums to 1?", attn.sum(axis=1).round(6))
print("\noutput (one context vector per token), shape", out.shape)
print(out)
attention weight matrix (rows = queries, cols = keys):
[[0.2836 0.3981 0.3183]
 [0.2823 0.4046 0.313 ]
 [0.3096 0.3546 0.3358]]

each row sums to 1? [1. 1. 1.]

output (one context vector per token), shape (3, 4)
[[-0.089  -0.5926 -0.195   0.7797]
 [-0.0879 -0.5928 -0.1969  0.7792]
 [-0.0891 -0.5788 -0.1805  0.7798]]

Read the attention matrix carefully, because it is the mechanism. Row 0 is token “the” acting as a Query: it attends 0.284 to “the” (itself), 0.398 to “cat,” and 0.318 to “sat.” Every row sums to exactly 1 — each token spreads a total attention budget of 1 across all tokens, which is what the softmax guarantees. Notice token 1 (“cat”) draws the most attention from every query (the middle column is largest in every row) — its Key happened to align with the Queries, so it dominates the blend. The output row for each token is that token’s Query-weighted average of the three Value vectors — its new, context-aware representation. This is not a metaphor for attention; it is attention, the same computation running inside every Transformer on earth.

Confirm it against PyTorch’s optimized kernel:

import torch, torch.nn.functional as F
Qt, Kt, Vt = torch.tensor(Q), torch.tensor(K), torch.tensor(V)
out_torch = F.scaled_dot_product_attention(Qt, Kt, Vt)
print("matches torch.nn.functional.scaled_dot_product_attention?",
      np.allclose(out, out_torch.numpy(), atol=1e-6))
# => matches torch.nn.functional.scaled_dot_product_attention? True

Our numpy attention equals PyTorch’s to machine precision. Two details in that code earn their place. The 1/√d_k scaling matters because dot products grow with dimension; without it, large scores push the softmax into a near-one-hot regime where gradients vanish. You can see the scaling gentle the scores:

raw scores QKᵀ:                scaled by 1/√4 = 0.5:
[[1.327 2.005 1.558]           [[0.663 1.003 0.779]
 [0.679 1.399 0.885]            [0.339 0.699 0.443]
 [1.151 1.423 1.314]]           [0.576 0.711 0.657]]

And the z - z.max() in the softmax is the numerical-stability trick from earlier lessons — it prevents exp from overflowing on large scores while leaving the result unchanged. Here is the operation as a table of shapes, the thing to keep straight when you build one:

Step Operation Shape (3 tokens, d=4) Meaning
Project Q,K,V = X @ W_{q,k,v} (3,4) each three views of each token
Score Q @ K.T (3,3) Query·Key similarity, all pairs
Scale / √d_k (3,3) keep scores modest
Weight softmax(row) (3,3), rows sum to 1 attention distribution per token
Blend weights @ V (3,4) context vector per token

Attention’s cost is written in that (3,3) score matrix: for n tokens it is n × n, so both compute and memory scale as O(n²). Fine for n=3; for a 100,000-token document it is 10 billion entries, and it is the central engineering wall of long-context Transformers (addressed by sparse, linear, and flash-attention variants). Remember that number — it reappears in the troubleshooting table.


Multi-head attention and positional encoding

Two refinements turn one attention operation into a Transformer layer, and both are essential rather than cosmetic.

Multi-head attention runs several attention operations in parallel — each with its own W_q, W_k, W_v — then concatenates their outputs. Why? One attention “head” learns one kind of relationship; with eight heads, the model can simultaneously track, say, syntactic agreement in one head, coreference (“it” → “animal”) in another, and positional adjacency in a third. Each head is lower-dimensional (the model dimension is split across heads, so it costs about the same as one full-width head), and the diversity is the point. It is the difference between reading a sentence once and reading it eight times looking for eight different things.

Aspect Single-head attention Multi-head attention
Projections one W_q,W_k,W_v set h independent sets (e.g. 8)
What it captures one relationship type many relationships at once
Dimension per head full d_model d_model / h (split)
Output one context vector h vectors concatenated, then projected
Cost O(n²·d) ≈ O(n²·d) (same, parallelized)

Positional encoding solves a subtle, fatal problem: self-attention has no notion of order. Look again at the attention formula — it is a sum over all tokens, and a sum does not care about order. Shuffle the input tokens and the outputs shuffle identically, but no token’s own representation changes. That means “dog bites man” and “man bites dog” would produce the same set of representations. For a mechanism meant to model language, that is a disaster, and it is easy to prove:

perm = [2, 0, 1]                              # shuffle the token order
def attention(X):
    Q, K, V = X @ W_q, X @ W_k, X @ W_v
    return softmax(Q @ K.T / np.sqrt(d_k)) @ V

out_perm = attention(X[perm])
print("attention(shuffled X) == shuffle of attention(X)?",
      np.allclose(out_perm, attention(X)[perm], atol=1e-6))
# => attention(shuffled X) == shuffle of attention(X)? True

True confirms the flaw: attention is permutation-invariant (more precisely, permutation-equivariant) — order carries no information whatsoever. The fix is to add a position-dependent signal to each token’s embedding before attention, so that “cat at position 1” and “cat at position 3” enter with different vectors. The original Transformer used fixed sinusoidal encodings — sines and cosines of the position at geometrically spaced frequencies:

def positional_encoding(seq_len, d_model):
    pos = np.arange(seq_len)[:, None]
    i = np.arange(d_model)[None, :]
    angle = pos / (10000 ** (2 * (i // 2) / d_model))
    return np.where(i % 2 == 0, np.sin(angle), np.cos(angle))

PE = positional_encoding(4, 4)
print("positional encodings for positions 0..3 (dim=4):")
print(PE.round(4))

def attend_with_pe(X):                         # stamp position onto each SLOT, then attend
    return attention(X + PE[:len(X)])

print("\nafter adding PE, still permutation-invariant?",
      np.allclose(attend_with_pe(X[perm]), attend_with_pe(X)[perm], atol=1e-6))
positional encodings for positions 0..3 (dim=4):
[[ 0.      1.      0.      1.    ]
 [ 0.8415  0.5403  0.01    1.    ]
 [ 0.9093 -0.4161  0.02    0.9998]
 [ 0.1411 -0.99    0.03    0.9996]]

after adding PE, still permutation-invariant? False

The flip from True to False is the whole lesson of positional encoding: once each position carries a distinct signature, shuffling the tokens genuinely changes the result — order now matters. Miss this step and your Transformer is a sophisticated bag-of-words. Modern models often use learned or rotary (RoPE) position encodings instead of sinusoids, but the necessity is identical.

Position scheme How it works Used by
Sinusoidal (fixed) sin/cos at geometric frequencies, added to embeddings original Transformer
Learned absolute a trainable vector per position BERT, GPT-2
Rotary (RoPE) rotate Q/K by position-dependent angles LLaMA, most recent LLMs
ALiBi bias attention scores by distance some long-context models

Encoder, decoder, and why Transformers won

A full Transformer stacks these attention layers into blocks, each block wrapping multi-head attention and a small position-wise MLP with two engineering staples that make deep stacks trainable — residual connections (add the input back to the output, giving gradients a clean path, the same vanishing-gradient remedy from the deep-net lesson) and layer normalization (stabilize activations). Blocks come in two flavours:

Block type Attention pattern Sees Job Example models
Encoder bidirectional self-attention the whole input at once build rich representations BERT (classification, embeddings)
Decoder masked (causal) self-attention only tokens so far generate the next token GPT family (text generation)
Encoder–decoder encoder + cross-attention input + generated output transform one sequence to another T5, translation

The decoder’s causal mask is what makes generation honest: when predicting word t, the mask zeroes out attention to words t+1 onward, so the model cannot “cheat” by peeking at the future it is supposed to predict. GPT-style models are decoder-only stacks; the whole of “generate the next token, append it, repeat” is masked self-attention run autoregressively.

Now the payoff — why the Transformer displaced the LSTM so completely. Two reasons, both consequences of removing recurrence:

Property RNN / LSTM Transformer
Parallelism within a sequence none — step t needs step t−1 full — all positions at once
Path length between distant tokens O(n) (through the hidden chain) O(1) (direct attention)
Training speed on long sequences slow (sequential) fast (GPU-parallel)
Long-range dependency modelling fades with distance direct, all-pairs
Memory / compute cost O(n) O(n²) (the tradeoff)
Positional information implicit in order of processing must be injected (positional encoding)

The first row is the one that changed history. An LSTM’s recurrence forbids parallelism — you cannot compute step 50 before step 49 — which wastes the massively parallel GPUs that modern training runs on. The Transformer computes all positions simultaneously (one big matrix multiply), so it saturates the hardware and trains dramatically faster, which means you can afford to train it on far more data and far more parameters. The second row — any token reaches any other in one attention hop instead of a fading chain — means long-range dependencies are modelled directly. Parallel training plus direct long-range modelling is the combination that made it practical to scale to billions of parameters and trillions of tokens. That scale is what produced large language models, and it is worth being precise and honest about what scale did and did not buy. (For the applied NLP side — tokenization, embeddings, fine-tuning a Transformer for classification — see NLP: Text Classification, Sentiment & Transformers.)


Scaling to LLMs: what pretraining actually buys

A large language model is a decoder-only Transformer with billions of parameters, trained on a large fraction of the public internet with one deceptively simple objective: predict the next token. That is the entire pretraining task — given a prefix, guess the next word-piece, and adjust weights when wrong, over trillions of tokens. Everything an LLM appears to “know” is a side effect of getting very, very good at that one prediction. Being honest about this is the difference between using LLMs well and being fooled by them.

Concept What it means Honest caveat
Pretraining next-token prediction over a huge corpus learns language patterns + world co-occurrences, not verified facts
Emergent behaviour capabilities (arithmetic, translation) appearing only past a scale threshold real but unpredictable; not “understanding”
In-context learning learning a task from examples in the prompt, no weight updates pattern-matching on the prompt, bounded by context window
Fine-tuning further training on task/domain data adapts style/format; can still hallucinate
RLHF reinforcement learning from human feedback to align outputs makes it helpful/harmless, not truthful
RAG retrieval-augmented generation: fetch documents, put them in the prompt grounds answers in real sources — the fix for hallucination
Prompting crafting the input to steer the output cheapest lever; brittle, model-specific

The two ideas that most surprise newcomers are in-context learning (show the model three examples of a task in the prompt and it does the fourth — with no gradient updates, just pattern completion) and emergent behaviour (some capabilities simply do not exist at small scale and appear, often abruptly, past some parameter/data threshold). Both are genuine and both are routinely over-read. An LLM is a spectacularly capable text predictor; it is not a database (it stores patterns, not verified records, and will fabricate a plausible citation as readily as a real one) and not a reasoner (it predicts what a reasoning trace looks like, which usually but not always coincides with correct reasoning). The engineering discipline that follows: for factual accuracy, ground it with retrieval (RAG) and tools; for reliability, verify its outputs; never treat a confident answer as a checked one. This honesty is not pessimism — it is the operating manual. The deployment side (serving models, latency, cost) is its own subject, covered in the MLOps material of this course.


Generative and other families, briefly and honestly

Not every architecture classifies or predicts; a whole branch of deep learning generates — learns the distribution of the data well enough to produce new samples. Four families matter, and one honest paragraph each is the right dose at this altitude.

Autoencoders learn to compress data to a small “bottleneck” code and reconstruct it, training on the reconstruction error alone (no labels — this is self-supervised). The encoder half is a learned, nonlinear dimensionality reducer (a more powerful cousin of PCA); the uses are compression, denoising, and anomaly detection (things that reconstruct badly are anomalies). A plain autoencoder is not really a generator — its latent space has holes — which is what the VAE fixes.

Variational autoencoders (VAEs) make the bottleneck a probability distribution rather than a point, so the latent space is smooth and continuous and you can sample new data from it. They generate coherent but characteristically blurry images — the probabilistic objective averages over possibilities. VAEs are prized when you want a well-behaved latent space to interpolate or manipulate, less so when you want crisp photorealism.

Generative adversarial networks (GANs) pit two networks against each other: a generator invents fake samples and a discriminator tries to tell fakes from real, each improving the other in a minimax game. GANs produced the first strikingly photorealistic faces (the “this person does not exist” era) and remain strong for sharp image synthesis, but they are notoriously unstable to train — mode collapse (the generator produces one thing), non-convergence, and delicate balance between the two networks are standard headaches.

Diffusion models learn to reverse a gradual noising process: take an image, add noise over many steps until it is pure static, then train a network to undo it one step at a time; to generate, start from pure noise and denoise into a sample. They now dominate high-quality image and audio generation (Stable Diffusion, DALL·E, Midjourney) because they train stably (unlike GANs) and produce sharp, diverse output (unlike VAEs). The cost is slow sampling — many denoising steps per image — though that gap is closing.

Family Learns by Generates Strength Honest weakness
Autoencoder reconstruction error (not really — compresses) denoising, anomaly detection latent space has gaps
VAE reconstruction + distribution match new samples from a smooth latent controllable latent space blurry output
GAN generator vs. discriminator game sharp, realistic samples photorealism unstable training, mode collapse
Diffusion reverse a noising process high-quality images/audio stable training + sharp + diverse slow sampling (many steps)

Reinforcement learning basics

Everything so far is supervised or self-supervised — you have a dataset and a target (a label, the next token, the input itself) and you minimize a loss against it. Reinforcement learning (RL) is a fundamentally different paradigm: there is no dataset of correct answers. An agent acts in an environment, and all it receives is a scalar reward — a number saying “that was good” or “that was bad,” often delayed and sparse. The agent must discover, by trial and error, a strategy that maximizes cumulative reward. This is how you train a game player, a robot, or the alignment step (RLHF) of an LLM — anywhere the right action is not labelled but evaluated.

The loop and its vocabulary are the foundation; learn these seven words and you can read any RL paper’s setup:

Term Meaning Gridworld example
Agent the learner/decider the thing moving on the grid
Environment the world it acts in the 4×4 grid
State (s) the current situation which cell the agent is in
Action (a) a choice available up / right / down / left
Reward ® scalar feedback after an action +1 for reaching the goal, else 0
Policy (π) the agent’s strategy: state → action “in cell 6, go right”
Value (Q or V) expected future reward how good is this state/action long-term

At each step the agent observes the state, picks an action via its policy, and the environment returns a reward and the next state; the agent updates its policy to do better. The central difficulty, which has no equivalent in supervised learning, is the exploration–exploitation tradeoff: should the agent exploit the best action it currently knows, or explore an untried action that might be better? Always exploiting gets stuck in a rut (it never discovers the shortcut); always exploring never cashes in what it learned. The standard cheap balance is ε-greedy: with probability ε take a random action (explore), otherwise take the best known action (exploit) — and typically decay ε from high to low, so the agent explores boldly early and exploits its knowledge later.

Q-learning and the Bellman update

Q-learning is the classic value-based algorithm. It learns a table Q[state, action] estimating the total future (discounted) reward of taking that action in that state and behaving well thereafter. The learning rule is the Bellman update — after taking action a in state s, seeing reward r, and landing in s':

$$Q(s,a) \leftarrow Q(s,a) + \alpha\big[,r + \gamma\max_{a’}Q(s’,a’) - Q(s,a),\big]$$

In words: nudge Q(s,a) toward the reward you just got plus the discounted value of the best next move. The bracket is the temporal-difference error — the gap between your old estimate and a better one built from real experience. α is the learning rate; γ (the discount factor, 0–1) trades immediate versus future reward — below 1, it makes the agent prefer reaching the goal sooner (each step of delay shaves value), which is exactly what makes it find short paths.

Let us watch it learn. A 4×4 gridworld, start at the top-left (state 0), goal at the bottom-right (state 15), reward +1 only at the goal. The agent knows nothing at the start — the Q-table is all zeros — and must learn purely from the reward signal:

import numpy as np
rng = np.random.default_rng(0)

N, GOAL = 4, 15
ACTIONS = {0: (-1, 0), 1: (0, 1), 2: (1, 0), 3: (0, -1)}   # up, right, down, left
ARROWS  = {0: "^", 1: ">", 2: "v", 3: "<"}

def step(s, a):
    r, c = divmod(s, N)
    dr, dc = ACTIONS[a]
    nr, nc = min(max(r + dr, 0), N - 1), min(max(c + dc, 0), N - 1)   # walls clamp
    ns = nr * N + nc
    return ns, (1.0 if ns == GOAL else 0.0), ns == GOAL

def greedy_policy(Q):
    return "\n".join(" ".join("G" if r*N+c == GOAL else ARROWS[int(np.argmax(Q[r*N+c]))]
                              for c in range(N)) for r in range(N))

def greedy_path_len(Q, cap=50):
    s, n = 0, 0
    while s != GOAL and n < cap:
        s, _, _ = step(s, int(np.argmax(Q[s]))); n += 1
    return n

alpha, gamma = 0.5, 0.95
Q = np.zeros((N * N, 4))
print("greedy policy BEFORE training (all-zero Q -> argmax picks 'up'):")
print(greedy_policy(Q))

for ep in range(500):
    eps = max(0.05, 1.0 - ep / 200)            # decay exploration 1.0 -> 0.05
    s = 0
    for _ in range(100):
        a = int(rng.integers(4)) if rng.random() < eps else int(np.argmax(Q[s]))
        ns, r, done = step(s, a)
        Q[s, a] += alpha * (r + gamma * Q[ns].max() - Q[s, a])       # Bellman update
        s = ns
        if done:
            break

print("\ngreedy policy AFTER 500 episodes:")
print(greedy_policy(Q))
print("\nQ-values at start state 0 (up,right,down,left):", Q[0].round(3))
print("optimal Q at start should be gamma^5 =", round(gamma ** 5, 4))
greedy policy BEFORE training (all-zero Q -> argmax picks 'up'):
^ ^ ^ ^
^ ^ ^ ^
^ ^ ^ ^
^ ^ ^ G

greedy policy AFTER 500 episodes:
> > v v
> > v v
> > > v
> > > G

Q-values at start state 0 (up,right,down,left): [0.735 0.774 0.774 0.735]
optimal Q at start should be gamma^5 = 0.7738

The learning is visible in the arrows. Before training, the all-zero Q-table’s argmax ties to action 0 (“up”) everywhere — a useless policy that would march the agent into the top wall forever. After 500 episodes, every cell’s arrow points toward the goal: the top rows say “go right,” the right column says “go down,” and following any arrow walks you to the bottom-right by the shortest route. Two numbers confirm the agent learned the optimal solution, not just a solution:

Adding one line inside the training loop — if ep+1 in (1,10,50,500): print(ep+1, greedy_path_len(Q)) — records how many steps the greedy policy takes from start to goal at each checkpoint (capped at 50 = “never arrives”):

after   1 episodes: never (>50) steps   (policy still useless)
after  10 episodes: 6 steps  <- optimal (3 rights + 3 downs)
after  50 episodes: 6 steps  <- optimal
after 500 episodes: 6 steps  <- optimal

The greedy path from start to goal drops from “never arrives” (before it has ever reached the goal) to exactly 6 steps — the true optimum for opposite corners of a 4×4 grid (3 rights + 3 downs). And the Q-value at the start for the best action is 0.774, matching the theoretical optimum γ⁵ = 0.95⁵ = 0.7738 almost exactly — because the +1 reward arrives 6 steps away and is discounted by γ once per step. The agent didn’t just stumble to the goal; it learned the discounted value of every move.

State Q-values (up, right, down, left) Best action Interpretation
0 (start, top-left) [0.735, 0.774, 0.774, 0.735] right or down (tie) 6 steps from goal → γ⁵ ≈ 0.774
14 (left of goal) [0.902, 1.000, 0.950, 0.901] right 1 step from goal → reward 1.0 undiscounted

One thing this run also teaches, by way of its own history: the exploration schedule was not incidental. An earlier attempt with a fixed low ε (0.1) never learned at all — the all-zero policy’s “up” bias sank the agent into the top-left corner, and 10% random moves were too few to ever stumble onto the distant goal, so the Q-table stayed all zeros. Decaying ε from 1.0 (pure exploration early, when the agent knows nothing) down to 0.05 fixed it. That is the exploration–exploitation tradeoff biting in practice, not in theory.

ε (exploration) strategy Behaviour Outcome here
Fixed low (ε = 0.1) from the start exploits a useless initial policy; barely explores never reaches goal — Q stays all zeros
Fixed high (ε = 0.5) explores well, but keeps acting randomly even when it knows better learns, but noisy/slow
Decaying (1.0 → 0.05) explores boldly early, exploits its knowledge late learns the optimal path by episode ~10

Beyond tables: policy gradients and deep RL

Tabular Q-learning works only when states and actions are few enough to enumerate — a 4×4 grid has 16 states. A game of Go has more states than atoms in the universe; you cannot make a table. The fix is to approximate the value or policy with a neural network, which is where deep RL lives. This lesson does not train a deep-RL agent — they are famously sample-hungry and unstable, and an honest training run does not fit in a lesson — so the following is the conceptual map, explicitly not executed here.

Method Learns Idea Known for
Q-learning (tabular) a value table Bellman update on Q[s,a] small discrete problems (what we ran)
DQN (Deep Q-Network) a value network neural net approximates Q(s,a); replay buffer + target net for stability Atari from pixels (DeepMind, 2013–15)
Policy gradient (REINFORCE) a policy directly nudge action probabilities up when returns are high continuous actions, stochastic policies
Actor–critic / PPO policy (actor) + value (critic) critic reduces the variance of the policy gradient the modern default; robotics, RLHF

Value-based methods (Q-learning, DQN) learn how good each action is and act greedily; policy-based methods (REINFORCE, PPO) learn the policy directly as a probability distribution over actions, which handles continuous action spaces (a robot joint angle) that a value table cannot. PPO (Proximal Policy Optimization), an actor–critic method that limits how far the policy moves per update for stability, is the workhorse behind most modern deep RL, including the RLHF that aligns LLMs.

Where RL genuinely shines, and where it genuinely hurts, in one honest ledger each:

RL wins where… Because
Games (Go, chess, Atari, StarCraft) clear reward (win/score), cheap simulation, self-play generates data
Robotics / control actions are sequential, no labelled “correct” trajectory exists
Recommendation / sequential decisions optimizing long-term engagement, not one click
RLHF for LLMs “good response” is evaluated by humans, not labelled — exactly RL’s shape
RL is brutally hard because… Symptom
Sample-inefficient needs millions–billions of interactions; only feasible with fast simulators
Unstable / high-variance small changes → wildly different outcomes; runs don’t reproduce
Reward design is treacherous agents exploit misspecified rewards literally (“reward hacking”)
Sparse/delayed rewards if reward comes only at the end, credit assignment is very hard

RL is not a drop-in replacement for supervised learning; it is the tool for problems shaped like sequential decisions evaluated by a reward, and it demands far more engineering care. Do not reach for it when a labelled dataset exists — supervised learning is vastly easier and more stable.


Which architecture for which problem

This is the master table — the practical distillation of the whole lesson. Start from the shape of your data and task, not from the architecture you find exciting.

Data / problem Reach for Why (the inductive bias) Not this
Tabular rows × columns gradient-boosted trees (XGBoost) first, then MLP no spatial/sequential structure to exploit CNN/RNN/Transformer overkill
Images, video (grids) CNN (or vision Transformer at scale) locality + translation invariance MLP (throws away spatial structure)
Short/local sequence patterns 1-D CNN local motifs, fast RNN if patterns are local only
Sequences, modest length, limited data LSTM / GRU order + memory, data-efficient Transformer (data-hungry)
Text / language, at scale Transformer / LLM long-range, parallel, pretrainable RNN (slow, forgets)
Long-range dependencies (any modality) Transformer (attention) all-pairs, O(1) path length RNN (fades over distance)
Generate images / audio diffusion (or GAN) stable, sharp, diverse samples VAE if you need crisp output
Compress / denoise / find anomalies autoencoder / VAE learned reconstruction GAN (no reconstruction)
Sequential decisions from a reward reinforcement learning no labels, learn from reward supervised (no labels exist)
Small data, need interpretability classical ML (linear, trees) deep nets overfit + opaque any deep architecture

The meta-rule, the same one from ML fundamentals: match the architecture’s inductive bias to your data’s structure, start with the simplest thing that could work, and escalate only when the data’s structure demands it. A Transformer is not “better than” an LSTM in the abstract — it is better when you have long-range dependencies, lots of data, and parallel hardware, and worse when you have a few thousand rows where its hunger for data makes it overfit. Architecture selection is not fashion; it is fit.


Hands-on lab

Reproduce the three headline results end to end. Everything is seeded, so your numbers will match. One venv, three short files.

Setup (once):

python3 -m venv .venv && source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install numpy
pip install torch --index-url https://download.pytorch.org/whl/cpu

Step 1 — an RNN forward pass, hidden state evolving. Put this in lab_rnn.py.

import numpy as np
rng = np.random.default_rng(0)
T, in_dim, hidden = 4, 2, 3
W_xh = rng.normal(0, 0.5, (in_dim, hidden))
W_hh = rng.normal(0, 0.5, (hidden, hidden))
xs = np.array([[1.,0.], [0.5,0.5], [0.,1.], [1.,1.]])

h = np.zeros(hidden)
for t in range(T):
    h = np.tanh(xs[t] @ W_xh + h @ W_hh)        # same weights reused every step
    print(f"step {t+1}: h = {h.round(4)}")
# => step 1: h = [ 0.0628 -0.066   0.3097]
# => step 2: h = [-0.2162 -0.1494  0.0341]
# => step 3: h = [-0.0336 -0.3162  0.2284]
# => step 4: h = [ 0.0279 -0.2695  0.3487]

What just happened: the hidden state started empty and carried a running summary forward; step 4’s memory depends on all four inputs through the recurrent weight. That is the entire idea of an RNN.

Step 2 — self-attention, weights that sum to 1. Put this in lab_attention.py.

import numpy as np
rng = np.random.default_rng(42)
d = 4
X = np.array([[1.,0,1,0], [0,2.,0,1], [1.,1,0,0]])          # 3 tokens
W_q, W_k, W_v = (rng.normal(0, .5, (d, d)) for _ in range(3))
Q, K, V = X @ W_q, X @ W_k, X @ W_v

def softmax(z):
    z = z - z.max(-1, keepdims=True); e = np.exp(z); return e / e.sum(-1, keepdims=True)

attn = softmax(Q @ K.T / np.sqrt(d))            # (3,3) attention weights
print("attention weights:\n", attn.round(4))
print("row sums:", attn.sum(1).round(6))        # => [1. 1. 1.]
out = attn @ V
print("output shape:", out.shape)               # => (3, 4)

import torch, torch.nn.functional as F
ok = np.allclose(out, F.scaled_dot_product_attention(
    torch.tensor(Q), torch.tensor(K), torch.tensor(V)).numpy(), atol=1e-6)
print("matches torch?", ok)                      # => matches torch? True

What just happened: each token attended to all three tokens; every row of the weight matrix summed to 1 (a softmax), and your numpy result matched PyTorch’s optimized kernel exactly. You computed the core of a Transformer by hand.

Step 3 — Q-learning learns the optimal path. Put this in lab_qlearn.py (the full script is in the RL section above). Run it and read the arrows:

BEFORE:            AFTER 500 episodes:
^ ^ ^ ^            > > v v
^ ^ ^ ^            > > v v
^ ^ ^ ^            > > > v
^ ^ ^ G            > > > G
Q[start] = [0.735 0.774 0.774 0.735]   (optimal γ⁵ = 0.7738)

What just happened: from an all-zero table and only a +1-at-the-goal reward, the agent learned a policy whose every arrow leads to the goal by the shortest 6-step route, and the start-state value matched the discounted-reward optimum. No labels — just reward and the Bellman update.

Step 4 — break the exploration (see why ε matters). Change the ε line in lab_qlearn.py to a fixed low value and rerun:

eps = 0.1                                # fixed, low — instead of the decaying schedule
# => greedy policy stays "all up"; Q-table stays ~all zeros; agent never reaches the goal

What just happened: with too little early exploration, the agent exploited its useless initial policy and never discovered the goal — the exploration–exploitation tradeoff, failing in front of you. Restore the decay to fix it.


Common mistakes and troubleshooting

Symptom / traceback Cause Fix
RNN loss stalls; can’t link distant events vanishing gradients over long sequences use LSTM/GRU, or switch to a Transformer
RNN loss → inf/nan on long sequences exploding gradients gradient clipping (cap the norm); smaller LR; better init
Transformer treats “dog bites man” = “man bites dog” forgot positional encoding — attention is permutation-invariant add positional encodings before the first attention layer
CUDA out of memory / RAM blows up on long input attention is O(n²) in sequence length shorter context; sparse/flash attention; chunk the input
RuntimeError: mat1 and mat2 shapes cannot be multiplied wrong Q/K/V or weight shapes print every .shape; W is (d_model, d_k); scores are (n, n)
Slow training, GPU underused, long sequences used an RNN where a Transformer fits Transformer parallelizes; RNN is sequential by construction
LLM invents a fake citation / wrong fact confidently treating an LLM as a database/reasoner ground with RAG + tools; verify outputs; never trust unchecked
Q-learning never improves; Q stays ~0 agent never reaches reward — too little exploration raise ε or decay it from high; check the reward is reachable
Q-learning oscillates / diverges learning rate α too high; or γ ≥ 1 lower α; keep 0 < γ < 1
RL agent finds a degenerate “cheat” reward misspecified (reward hacking) redesign the reward; penalize the exploit; add shaping carefully
RL learns nothing on a hard task sparse/delayed reward, poor credit assignment reward shaping; curriculum; a simpler sub-task first
Deep-RL run won’t reproduce; wildly different each seed RL is high-variance / unstable average over seeds; use PPO; tune carefully; expect fragility
Expecting RL to work like supervised learning wrong mental model — no labelled dataset if labels exist, use supervised learning instead
nn.Transformer output ignores order didn’t add positional encoding / used wrong mask add PE; use a causal mask for generation
Deep net on 2,000 tabular rows loses to XGBoost wrong architecture for the data structure match bias to data — tabular → boosting first

Three failures cost the most time, in prose:

Forgetting positional encoding is the classic Transformer bug, and it is silent. The model trains, the loss falls, nothing errors — but the network is a bag-of-words that cannot tell “dog bites man” from “man bites dog,” because self-attention is permutation-invariant (we proved it: shuffle the tokens and the outputs just shuffle). There is no traceback; the only symptom is that order-dependent tasks plateau at mediocre accuracy. The discipline: whenever you assemble attention layers yourself, inject position first and verify — feed a sequence and its reverse and confirm the outputs differ. Frameworks’ high-level Transformer modules still expect you to add the encoding; it is not automatic.

Q-learning that never improves is almost always an exploration problem, not a learning-rate problem. Beginners tune α and γ for hours when the real issue is that the agent, acting greedily on a useless initial policy, never reaches the reward at all — so there is nothing to learn from, and the Q-table stays zero (we hit exactly this: fixed low ε left the agent sunk in the corner, never touching the goal). The fix is exploration: start ε high (even 1.0, fully random) so the agent stumbles onto the reward, then decay it. The diagnostic question is always “has the agent ever received the reward?” — if not, no amount of learning-rate tuning helps.

Reaching for the wrong architecture wastes weeks, and the tell is a mismatch between bias and data. An RNN on a long-sequence task with abundant data and GPUs is leaving enormous training speed on the table (it can’t parallelize) — use a Transformer. A Transformer on a few thousand tabular rows will overfit and lose to gradient boosting — use trees. A deep net on 500 examples will memorize them — use a linear model. The single most valuable habit is to name your data’s structure first (grid? sequence? long-range? agent-in-environment? flat table?) and let that pick the family, rather than picking a fashionable architecture and forcing your data into it.


Cheat-sheet

Concept Key formula / code Note
RNN cell h_t = tanh(x_t @ W_xh + h_{t-1} @ W_hh + b) same weights reused every step
RNN failure vanishing/exploding gradient over time fix: LSTM/GRU or Transformer
LSTM memory C_t = f·C_{t-1} + i·C̃ (gated, additive) additive highway → long-range memory
GRU LSTM with 2 gates, no separate cell state fewer params, ~same performance
Self-attention softmax(Q @ K.T / √d_k) @ V each token attends to all; rows sum to 1
Q, K, V X @ W_q, X @ W_k, X @ W_v query / key / value projections
Scaling divide scores by √d_k keeps softmax gradients healthy
Multi-head h parallel attentions, concatenated captures many relations at once
Positional encoding add PE[pos] to embeddings mandatory — attention ignores order
Attention cost O(n²) in sequence length the long-context wall
Encoder / decoder bidirectional / masked (causal) attention BERT / GPT
Transformer win parallel + O(1) path length why it beat the RNN
LLM decoder-only Transformer, next-token pretraining not a database, not a reasoner
RAG retrieve docs → put in prompt the fix for hallucination
RL loop state → action → reward → next state learn from reward, no labels
Bellman update Q[s,a] += α(r + γ·max Q[s'] − Q[s,a]) temporal-difference learning
ε-greedy random w.p. ε, else best action explore vs. exploit; decay ε
Discount γ 0–1, weights future reward <1 → prefers shorter paths
Value vs policy RL learn Q and act greedy / learn π directly DQN vs. PPO
Architecture rule match inductive bias to data structure grid→CNN, seq→RNN, long-range→Transformer, agent→RL

Interview and exam questions

Q: What does it mean to say “an architecture is an inductive bias matched to the data”? Give three examples. A: An inductive bias is a built-in assumption about the data’s structure that lets a model learn efficiently from the right shape of input. A CNN assumes locality and translation invariance (good for images: nearby pixels relate, a cat is a cat anywhere). An RNN assumes order matters and the past informs the present (good for sequences). A Transformer assumes any element may depend on any other regardless of distance (good for long-range dependencies). Matching the bias to the data is why you pick a CNN for images and an RNN/Transformer for text rather than a plain MLP for both.

Q: Why does a plain MLP struggle with images and with sequences? A: With images, the MLP has no notion of spatial locality — flattening a grid throws away the fact that neighbouring pixels are related, forcing it to relearn every spatial relationship with a separate weight per pixel-neuron pair (millions of parameters). With sequences, it has a fixed input width (can’t take variable-length input) and treats input positions as unrelated slots (no built-in sense of order or flow). CNNs fix the first via shared local filters; RNNs and Transformers fix the second.

Q: Explain the vanishing/exploding gradient problem in RNNs. Why does it happen and how is it addressed? A: Training an RNN backpropagates through time, multiplying by the recurrent Jacobian once per time step you travel back. Repeated multiplication by the same matrix is exponential: if its spectral radius is below 1, gradients vanish toward zero (early steps stop learning — can’t capture long-range dependencies); above 1, they explode toward inf/nan. Exploding gradients are patched with gradient clipping; vanishing gradients are structural and led to the LSTM/GRU, whose gated additive cell state lets memory and gradients flow across many steps without shrinking. Transformers avoid the problem entirely by removing recurrence.

Q: How does an LSTM solve what a vanilla RNN cannot? A: An LSTM adds a separate cell state — a memory conveyor belt updated as C_t = forget·C_{t-1} + input·candidate, mostly by addition — plus sigmoid gates (forget, input, output) that decide what to erase, write, and read. Because memory travels along the cell state additively (derivative ≈ the forget gate, near 1) rather than through a repeated matrix multiply, the gradient does not vanish, so an LSTM learns long-range dependencies a vanilla RNN forgets. A GRU is a streamlined version with two gates and no separate cell state.

Q: Walk through scaled dot-product self-attention. Why does each row of the attention matrix sum to 1, and why divide by √d_k? A: Each token is projected to a Query, Key, and Value. Scores are all-pairs dot products Q @ K.T (how well each Query matches each Key), scaled by 1/√d_k, then softmaxed row-wise into weights, which multiply the Values to produce each token’s context vector. Each row sums to 1 because it is a softmax — each token distributes a total attention budget of 1 across all tokens. The √d_k scaling counteracts dot products growing with dimension; without it, large scores push the softmax toward one-hot, where gradients vanish and training stalls.

Q: Why is positional encoding necessary in a Transformer? What breaks without it? A: Self-attention is a sum over all tokens, which is permutation-invariant — shuffle the input and the outputs just shuffle, so no token’s representation encodes where it sits. Without positional encoding the model is a bag-of-words: “dog bites man” and “man bites dog” produce identical representations. You add a position-dependent signal (sinusoidal, learned, or rotary) to each embedding before attention so identical tokens at different positions enter differently. It fails silently — no error, just poor accuracy on order-dependent tasks.

Q: Why did Transformers largely replace RNNs/LSTMs? A: Two consequences of removing recurrence. First, parallelism: an RNN must compute step t after step t−1, so it can’t use parallel hardware within a sequence; a Transformer computes all positions at once (one matrix multiply), training far faster and thus scaling to far more data and parameters. Second, path length: any two tokens are connected directly by attention (O(1)) instead of through a fading O(n) hidden-state chain, so long-range dependencies are modelled directly. The cost is O(n²) attention memory. Parallel + direct long-range is what enabled LLM-scale training.

Q: In one honest paragraph, what is an LLM — and what is it not? A: A large language model is a decoder-only Transformer with billions of parameters, pretrained to predict the next token over a huge corpus. That single objective yields fluent language, in-context learning (doing a task from prompt examples with no weight updates), and emergent capabilities past certain scales. It is not a database (it stores statistical patterns, not verified records, and will fabricate plausible-looking facts and citations) and not a reasoner (it predicts what reasoning looks like, which usually but not always matches correct reasoning). For accuracy, ground it with retrieval (RAG) and tools and verify outputs; never treat a confident answer as a checked one.

Q: Contrast VAEs, GANs, and diffusion models as generators. A: A VAE learns a probabilistic latent space and samples from it via a reconstruction-plus-distribution objective — stable and controllable but characteristically blurry. A GAN pits a generator against a discriminator in a minimax game — sharp, photorealistic output but notoriously unstable to train (mode collapse, non-convergence). A diffusion model learns to reverse a gradual noising process, generating by denoising from pure noise — currently dominant for high-quality images because it trains stably and produces sharp, diverse samples, at the cost of slow multi-step sampling.

Q: Explain the reinforcement-learning loop and the exploration–exploitation tradeoff. A: An agent observes a state, picks an action via its policy, and the environment returns a reward and the next state; the agent updates its policy to maximize cumulative reward — with no labelled dataset, only the reward signal. Exploration–exploitation is the core dilemma: exploit the best known action (risk getting stuck in a rut, never finding the better path) versus explore an untried action (risk wasting a step). ε-greedy balances them — act randomly with probability ε, else greedily — and you typically decay ε from high to low so the agent explores boldly early and exploits its knowledge later.

Q: Write the Q-learning Bellman update and explain each term. A: Q(s,a) ← Q(s,a) + α[r + γ·max_a' Q(s',a') − Q(s,a)]. Q(s,a) is the current estimate of the total future reward for taking action a in state s; α is the learning rate (step size); r is the immediate reward just received; γ is the discount factor (0–1, weighting future vs. immediate reward, and <1 makes the agent prefer reaching the goal sooner); max_a' Q(s',a') is the best achievable value from the next state. The bracket is the temporal-difference error — the gap between the old estimate and a better one built from real experience — and you nudge Q(s,a) toward closing it.

Q (practical): Why is reinforcement learning so much harder to get working than supervised learning? A: It is sample-inefficient (needs millions–billions of interactions, feasible only with fast simulators), high-variance and unstable (small changes cause wildly different outcomes; runs often don’t reproduce), and reward design is treacherous (agents exploit any misspecification literally — “reward hacking”). Sparse or delayed rewards make credit assignment very hard (which of 100 actions earned the reward?). There is no labelled “correct answer” to regress against — the agent must discover good behaviour by trial and error. If a labelled dataset exists for your problem, supervised learning is dramatically easier and more stable; reserve RL for problems genuinely shaped like sequential decisions evaluated by a reward.


Key takeaways

pythondeep-learningrnnlstmtransformersself-attentionreinforcement-learningq-learningneural-networkspytorchnumpyllmarchitecturesattention
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments