April 9, 2026
Last lecture we built the full transformer for machine translation:
What does self attention do?
What does masked self attention do?
What does cross attention do?
| Component | Mechanism | What It Does |
|---|---|---|
| Encoder | Self-attention | Build context for input tokens |
| Decoder | Masked self-attention | Build context for output tokens (causal) |
| Decoder | Cross-attention | Pull information from encoder into decoder |
The encoder runs fully in parallel — every position attends to every other position simultaneously.
The decoder’s masked self-attention also runs in parallel during training — the causal mask handles the “no peeking” constraint without requiring sequential processing.
But cross-attention is the part that creates the bottleneck. Why?
Cross-attention couples the encoder and decoder. Every decoder position attends to every encoder position.
During training: this is fine — both sequences are fully known, and we can compute all attention scores in parallel.
During inference: we generate the output sequence one token at a time.
The cross-attention itself isn’t sequential — but the decoder generation is sequential, and each step requires a full cross-attention pass against the encoder. You can’t parallelize across tokens during generation because each token depends on the previous one.
Some researchers asked: do we even need the encoder?
If we eliminate cross-attention entirely, we can build models that are purely self-attention over a single sequence — much simpler, much more parallelizable, and (it turns out) much more powerful.
Two paths:
Today: the decoder-only path. Next lecture: the encoder-only path.
Take the full transformer and remove the entire encoder.
Also remove the cross-attention layers from the decoder — they have nothing to attend to.
What’s left:
That’s it. The simplest possible transformer.
class GPTBlock(nn.Module):
def __init__(self, embed_dim, num_heads, ff_dim):
super().__init__()
self.ln1 = nn.LayerNorm(embed_dim)
self.attn = nn.MultiheadAttention(embed_dim, num_heads)
self.ln2 = nn.LayerNorm(embed_dim)
self.ffn = nn.Sequential(
nn.Linear(embed_dim, ff_dim),
nn.GELU(),
nn.Linear(ff_dim, embed_dim),
)
def forward(self, x, mask):
x = x + self.attn(self.ln1(x), self.ln1(x), self.ln1(x), attn_mask=mask)[0]
x = x + self.ffn(self.ln2(x))
return xStack \(N\) of these blocks. GPT-1 used 12. GPT-2 used 48. GPT-3 used 96. The architecture is the same — just deeper and wider.
class GPT(nn.Module):
def __init__(self, vocab_size, max_len, embed_dim, num_heads, num_layers):
super().__init__()
self.token_embed = nn.Embedding(vocab_size, embed_dim)
self.pos_embed = nn.Embedding(max_len, embed_dim)
self.blocks = nn.ModuleList([
GPTBlock(embed_dim, num_heads, 4 * embed_dim)
for _ in range(num_layers)
])
self.ln_final = nn.LayerNorm(embed_dim)
self.head = nn.Linear(embed_dim, vocab_size)
def forward(self, tokens):
B, T = tokens.shape
pos = torch.arange(T, device=tokens.device)
x = self.token_embed(tokens) + self.pos_embed(pos)
mask = torch.triu(torch.ones(T, T), diagonal=1).bool()
for block in self.blocks:
x = block(x, mask)
x = self.ln_final(x)
return self.head(x) # (B, T, vocab_size) — logits for each positionInput: a sequence of token IDs. Output: a distribution over the vocabulary at every position.
GPT is trained on one task: predict the next token.
Given a sequence \([x_1, x_2, \ldots, x_T]\), the model learns to predict:
\[P(x_2 | x_1), \quad P(x_3 | x_1, x_2), \quad \ldots, \quad P(x_T | x_1, \ldots, x_{T-1})\]
The loss is cross-entropy, summed over all positions:
\[\mathcal{L} = -\sum_{t=2}^{T} \log P(x_t | x_1, \ldots, x_{t-1})\]
This is maximum likelihood estimation over the training corpus.
Without the mask, position \(t\) could see positions \(t+1, t+2, \ldots\) during training. The model would learn to “predict” the next token by just looking at it directly. No learning happens.
With the causal mask, position \(t\) can only attend to positions \(1, \ldots, t\). When we ask “what’s the distribution over token \(t+1\)?”, the model must use only the past to make that prediction.
The mask is what turns a transformer into a language model.
Here’s where the decoder-only architecture really shines.
For a sequence of length \(T\), we want to train the model on \(T-1\) prediction tasks:
In a Seq2Seq RNN, these would require \(T-1\) separate forward passes — sequential, slow.
In GPT, all \(T-1\) predictions happen in one forward pass. The causal mask ensures each position only sees the appropriate context, and the model outputs a distribution at every position simultaneously.
What does GPT learn?
The whole semester we’ve been thinking about generation as a distribution problem:
Specifically, GPT learns the conditional next-token distribution:
\[P(x_{t+1} | x_1, x_2, \ldots, x_t)\]
For GPT-2, the vocabulary has ~50,000 tokens. So at every position, the model outputs a probability distribution over 50,000 possible next tokens.
By the chain rule of probability, this conditional distribution gives us the full joint distribution over sequences:
\[P(x_1, x_2, \ldots, x_T) = P(x_1) \cdot P(x_2 | x_1) \cdot P(x_3 | x_1, x_2) \cdots P(x_T | x_1, \ldots, x_{T-1})\]
Given the selection of the 50,000 tokens we have already seen, what is the distribution of the next token over the same 50k words?
GPT is a giant probabilistic model of all possible text sequences!
How do we generate text? Sample from the distribution autoregressively.
Each step conditions on everything that came before — both the original prompt and all the tokens we’ve sampled so far.
The probabilistically pure thing to do is sample from the full distribution at every step.
In practice, this often produces:
The solution: modify how we sample to bias toward higher-quality outputs while still maintaining diversity.
We give up some probabilistic purity to gain practical quality.
The simplest strategy: always pick the highest-probability token.
Deterministic. Same prompt always gives the same output. Maximum likelihood at each individual step.
Almost always terrible in practice:
Maximum likelihood at each step \(\neq\) maximum likelihood of the full sequence.
Sample from the distribution, but first control how peaked it is with a temperature parameter:
\[P(x_t = i) = \frac{\exp(z_i / \tau)}{\sum_j \exp(z_j / \tau)}\]
where \(z_i\) are the raw logits and \(\tau\) is the temperature.
Temperature is the dial you actually see in ChatGPT and Claude APIs.
Top-k: only sample from the top \(k\) most probable tokens. Zero out everything else, renormalize, then sample.
Typical values: \(k = 40\) or \(k = 50\).
Limitation: the fixed \(k\) doesn’t adapt to the shape of the distribution. If the model is very confident (top 2 tokens have 99% mass), \(k = 50\) still lets you sample from 48 unlikely tokens.
Top-p (Holtzman et al., 2020): instead of a fixed number of tokens, use a dynamic number that captures a fixed probability mass.
Sort tokens by probability. Keep the smallest set whose cumulative probability exceeds \(p\). Sample from this “nucleus.”
Example with \(p = 0.9\):
The nucleus adapts to the model’s confidence:
Typical values: \(p = 0.9\) to \(0.95\). This is the default in most modern systems.
In practice, most modern systems combine three pieces:
top-k as a safety net (\(k = 50\))
then top-p (\(p = 0.9\)),
then temperature.
Three dials working together.
All the methods so far are stochastic — you sample one path through the space of possible sequences.
Beam search tries to find high-probability sequences deterministically by maintaining multiple candidates.
At each step, keep the top \(B\) partial sequences. Expand each by all possible next tokens. Keep the top \(B\) of the resulting expansions. Repeat until done.
Beam search was the standard for translation in the pre-LLM era. For modern chatbots, stochastic sampling is preferred.
| Use case | Recommended strategy |
|---|---|
| Creative writing | Temperature 0.8-1.2, top-p 0.9 |
| Factual Q&A | Temperature 0.3-0.7, top-p 0.9 |
| Code generation | Temperature 0.2-0.5, top-k 40 |
| Translation | Beam search (width 4-8) |
| Brainstorming | Temperature 1.2+, top-p 0.95 |
The sampling strategy is often more important than people realize. A well-tuned GPT-3.5 with good sampling can outperform a poorly-tuned GPT-4 with greedy decoding on many tasks.
Try this with GPT-2 and any sampling strategy:
Prompt: "The old lighthouse keeper noticed something strange. It was"
GPT-2: "...a small, white object that looked like a piece of paper.
The man picked it up and looked at it. It was a small, white
object. He looked at it again. It was a small, white object..."
No sampling strategy fixes this. You can crank temperature, lower top-p, do beam search — the output is still mediocre.
The problem isn’t sampling. It’s the model.
Sampling strategies are post-hoc fixes for problems in the underlying distribution.
If the model has learned a good distribution, sampling strategies smooth out the rough edges. Temperature controls randomness. Top-p prevents long-tail nonsense. These are useful tools.
If the model has learned a bad distribution, sampling strategies can’t help. You can’t sample your way to good output from a model that doesn’t know what good output looks like.
If you had to guess, what do you think GPT-2 was trained on?
| Model | Training data | Approx. token count |
|---|---|---|
| GPT-1 | BookCorpus | ~1B tokens |
| GPT-2 | WebText + Books | ~10B tokens |
| GPT-3 | Common Crawl + Books + Wikipedia | ~500B tokens |
| GPT-4 | Web + books + code + ??? | ~13T tokens (estimated) |
| Llama 3 | Public web + curated sources | 15T tokens |
The jump from GPT-2 to GPT-3 was as much about data scale as it was about parameter scale.
The jump from GPT-3 to GPT-4 was as much about data quality as it was about data quantity. GPT-4 used heavily filtered, deduplicated, and curated training data — including a lot of “legally” obtained content (the legal status of much of this data is, shall we say, contested).
Self-attention is doing something deep: it’s a non-parametric, non-linear estimator of the conditional dependence structure in the input.
Each attention layer learns: “given everything I’ve seen so far, which positions are most relevant to predict what comes next?”
This is essentially a learned, context-dependent covariance estimator.
Like all deep models, more capacity means the model can represent more complex dependence structures.
Language is extraordinarily complex. More parameters \(\to\) more capacity \(\to\) more humanlike output.
| Model | Year | Parameters | Training Compute | Notes |
|---|---|---|---|---|
| GPT-1 | 2018 | 117M | ~1 PF-day | Proof of concept |
| GPT-2 | 2019 | 1.5B | ~10 PF-days | “Too dangerous to release” |
| GPT-3 | 2020 | 175B | ~3,640 PF-days | Modern |
| GPT-4 | 2023 | ~1.7T (est.) | ~50,000 PF-days (est.) | Closed weights |
| Claude Opus 4.1 | 2025 | Trillions | Decades | Understanding |
| Gemini 3 Pro | 2025 | Trillions | Decades | Tech marvel |
Training GPT-3 on a single GPU would take thousands of years. It’s only feasible because GPT training parallelizes cleanly:
All of this works because the computational graph is dense and predictable. Self-attention is just matrix multiplications. Every operation parallelizes.
Training is embarrassingly parallel. Inference is not.
At inference time, we generate one token at a time. Each token requires a full forward pass through the model, and we can’t start generating token \(t+1\) until we’ve finished token \(t\).
This is why ChatGPT sometimes feels slow, why API costs scale with tokens generated, and why “smaller, faster” models have a huge market. Inference speed is the bottleneck for real-world deployment.
More parameters \(\to\) better quality, but also slower inference.
More data centers
More hardware buyouts
More electricity used
More natural resources consumed
We can be better, though!
In the notebook, we’ll see Gemma 2B — a small, efficient model that runs on a laptop and produces surprisingly good output thanks to better training data and modern techniques.
What if we treated an image as a sequence of pixels and applied next-token prediction directly?
This is exactly what early models did:
Figure saved as 'pixel_rnn_approach.png'
It worked in principle. The model learned to generate coherent images.
It didn’t scale in practice. A 256×256 image is 65,536 pixels. Each pixel has 256³ possible values. Even with quantization tricks, the sequence length and vocabulary size are enormous.
Image GPT could generate decent 32×32 images. Scaling to 256×256 was computationally infeasible.
The VQ-VAE solves this elegantly.
Instead of treating each pixel as a token:
The spatial/autoregressive structure of images is preserved, but the sequence length and vocabulary size are now manageable.
Figure saved as 'vqvae_compression_comparison.png'
Build a decoder-only transformer that takes flattened codebook index sequences as input:
Training: same as text GPT. Cross-entropy loss on next-token prediction, fully parallel via causal masking.
Generation: sample one codebook index at a time, conditioned on the indices generated so far. After 16 sampling steps, you have a complete codebook map. Pass it through the VQ-VAE decoder to get an image.
One issue: when we flatten the 4×4 grid into a sequence of 16 tokens, we lose spatial information.
Position 5 is actually at row 1, column 1. Position 10 is at row 2, column 2. The decoder needs to know this.
Solution: 2D RoPE. Apply rotary positional embeddings separately for the row and column dimensions.
The result: every codebook index “knows” where it sits in the 2D grid. Spatial structure is preserved despite the 1D sequential processing.
How do we tell the model to generate “a husky” instead of “a chihuahua”?
Prepend the conditioning as tokens. Treat the breed and color labels as additional tokens at the start of the sequence:
[BREED=husky] [COLOR=gray] [s_1] [s_2] ... [s_16]
The transformer’s masked self-attention naturally handles this. Every codebook position attends to the conditioning tokens (and to all earlier codebook positions). The model learns:
\[P(s_i | \text{conditioning}, s_1, \ldots, s_{i-1})\]
At generation time, fix the conditioning tokens and sample the rest.
Back in Lecture 20, we conditioned the VQ-VAE itself by passing labels into the encoder and decoder.
That worked, but it was finicky:
Conditioning the transformer is cleaner:
This is the standard approach for modern image generators.
You’ve now built every component of DALL-E 1:
The only piece DALL-E 1 has that we haven’t built yet: CLIP text embeddings as the conditioning signal instead of our categorical breed/color tokens.
That’s what we’ll cover in two lectures. By the end of the CLIP lecture, the picture will be complete!
Today: the decoder-only path. Masked self-attention, autoregressive generation, GPT.
But there’s another side. What if we used the encoder only?
This is the path that produced BERT (for text) and ViT (for images). It turns out to be the SOTA for nearly every downstream task that isn’t generation: classification, retrieval, similarity, embedding-based search, transfer learning.
Next lecture: encoder-only models, representation learning, and how the same transformer architecture serves a completely different purpose.