Ch. 14
NLP with RNNs + Attention
Word embeddings, encoder-decoder, Bahdanau → Luong → self-attention. The historical pivot point.
For five years between Sutskever 2014 and Vaswani 2017, the way you built a translation system was: read the source sentence with an RNN, compress its meaning into a single A single bundle of numbers meant to summarize a whole input sequence.Full glossary →, hand that vector to another RNN that produced the target sentence one word at a time. It worked. Until the source sentence got longer than about twenty words. Then it stopped working in a very specific way: the system would translate the first half correctly and start hallucinating the second half, as if it had forgotten what came at the start. Bahdanau, Cho, and Bengio's 2014 paper looked at that failure and asked: what if, instead of compressing everything into one vector, the decoder could look back at the encoder's hidden states whenever it needed to? They called the look-back operation "A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →". By the end of this chapter you will have built word embeddings, an A two-part design where one half squeezes the input into a compact summary and the other half expands it into the output.Full glossary →, additive attention, multiplicative attention, and A smarter way to build a sentence that keeps the few most promising options alive at once instead of committing to one.Full glossary →. You will also have crossed the bridge to where transformers begin.
FIG 14.1 · Learning outcomes
By the end of this chapter you will be able to:
- Train word2vec skip-gram embeddings on a small corpus in 80 lines of NumPy.
- Tokenize text three ways (whitespace, BPE, byte-level) and articulate the trade-offs between them.
- Build an encoder-decoder GRU and train it on a tiny English-to-French dataset.
- Implement Bahdanau (additive) attention from scratch and explain why the additive form was chosen first.
- Implement Luong (multiplicative / dot-product) attention and explain why it scaled better.
- Generalise the same equations into self-attention, and recognise it as the operation another chapter will build on.
- Implement beam search with width $k$ in 40 lines, and articulate when greedy is wrong.
- Trace the historical chain Bahdanau → Luong → self-attention → Vaswani 2017, in correct order, and say what each step contributed.
FIG 14.2 · What you need first
- Ch 13 — Sequences and Time Series — RNNs, LSTM, GRU, encoder-decoder. The chapter assumes you have built a recurrent cell from scratch.
- Ch 10 — PyTorch —
nn.Module, embedding layers,DataLoader. - Ch 0 — Math & Python prereqs — softmax, dot products, basic probability. Section 4 of another chapter is the linear-algebra prereq for attention.
This is the chapter where the transformer's main idea (attention) appears for the first time, in the pre-transformer form. another chapter picks up exactly where this one ends.
FIG 14.3.1
Tokenization: bytes to integers
Before any model sees a word, the word has to become an integer. Chopping text into small pieces and giving each piece a number, because models can only work with numbers.Full glossary → is the choice of how to split text into pieces and assign each piece an ID. The pieces are called tokens.
Three approaches you should be able to name and compare:
Whitespace / word-level. Split on spaces and punctuation. One A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → per word. Simple, but the The fixed set of all chunks a model is allowed to read or produce.Full glossary → explodes (English has hundreds of thousands of distinct words once you count inflections and proper nouns), A word the model never saw in training, so it has no stored meaning for it.Full glossary → words are common, and you cannot generalise to misspellings.
Character-level. One token per Unicode code point. Vocabulary of ~100 (ASCII) to ~150,000 (all Unicode), no out-of-vocab problem, but sequence length explodes (a 100-character sentence is 100 tokens) and the model spends most of its compute on redundancy.
Subword (A way of splitting text where common letter pairs and word-pieces get merged into reusable chunks.Full glossary →, WordPiece, SentencePiece). The dominant choice since GPT-2. Start with single characters or bytes. Repeatedly find the most frequent adjacent pair in the training corpus, merge them into a new token. After ~50k merges, common words become single tokens (the, and, of), rare words become subword sequences (tokenization → token, ization). Pre-trained models all use some variant: GPT uses byte-level BPE, BERT uses WordPiece, T5 and Llama use SentencePiece.
# A tiny byte-pair encoding pass
from collections import Counter
def get_pair_counts(seq: list[int]) -> Counter:
return Counter(zip(seq, seq[1:]))
def merge(seq: list[int], pair: tuple, new_id: int) -> list[int]:
out, i = [], 0
while i < len(seq):
if i < len(seq) - 1 and (seq[i], seq[i+1]) == pair:
out.append(new_id); i += 2
else:
out.append(seq[i]); i += 1
return out
def train_bpe(text: str, target_vocab: int = 1000) -> tuple[list[int], dict]:
seq = list(text.encode("utf-8"))
merges, next_id = {}, 256
while next_id < target_vocab:
pairs = get_pair_counts(seq)
if not pairs: break
best = pairs.most_common(1)[0][0]
seq = merge(seq, best, next_id)
merges[best] = next_id
next_id += 1
return seq, mergesThis is the warm-up. another chapter has the full BPE deep-dive (with regex pre-tokenization, special tokens, the GPT-2 pattern). For now, internalise the idea: tokenization is a learned compression of bytes into integers, with a vocabulary size you choose, and the choice has downstream consequences.
FIG 14.3.2
Word embeddings: vectors as meanings
Once you have A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → IDs, you turn each one into a vector. The lookup is exactly nn.Embedding(vocab_size, d_embed): a learned table of shape (vocab_size, d_embed) where each row is one token's vector.
What makes embeddings interesting is not the lookup; it is what the rows learn to encode. After training, similar words end up at similar positions in the A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → space. The canonical demonstration is the analogy structure: in word2vec's vector space. This is not a property anyone designed in. It emerges because the training objective (predict surrounding context from a target word, or vice versa) forces the geometry to encode contextual co-occurrence patterns, and those patterns turn out to capture meaningful semantic axes.
Three pre-transformer ways to learn embeddings:
word2vec (Mikolov et al. 2013). Two variants: skip-gram (given a target word, predict the surrounding context words) and CBOW (given context, predict the target). Both train a shallow network. The middle-layer weights become the embeddings.
GloVe (Pennington, Socher, Manning 2014). Factorise the global word-word co-occurrence matrix (after log-transform) into low-rank embedding matrices. A matrix-factorisation reframing of word2vec.
fastText (Bojanowski et al. 2017). Like word2vec, but each word is represented as the sum of its character n-gram embeddings — an n-gram being a run of consecutive characters, so where at contributes <wh, whe, her, ere, re>. Handles A word the model never saw in training, so it has no stored meaning for it.Full glossary → words (you can embed any string by summing its n-gram embeddings) and learns better representations for morphologically rich languages.
gensim Word2Vec (skip-gram + negative sampling) vs. from scratch
Classical MLmodel = Word2Vec(sentences, vector_size=100, window=5,
sg=1, negative=5, min_count=1)
vec = model.wv["cat"] # learned input-side embedding
model.wv.most_similar(positive=["king", "woman"], negative=["man"])class SkipGramNeg(nn.Module):
def __init__(self, vocab_size, d_embed):
super().__init__()
self.in_emb = nn.Embedding(vocab_size, d_embed)
self.out_emb = nn.Embedding(vocab_size, d_embed)
def forward(self, target, context, neg):
v_t = self.in_emb(target) # (B, D)
v_c = self.out_emb(context) # (B, D)
v_n = self.out_emb(neg) # (B, k, D)
pos_score = (v_t * v_c).sum(dim=-1)
neg_score = torch.bmm(v_n, v_t.unsqueeze(-1)).squeeze(-1)
pos_loss = -torch.log(torch.sigmoid(pos_score) + 1e-9)
neg_loss = -torch.log(torch.sigmoid(-neg_score) + 1e-9).sum(dim=-1)
return (pos_loss + neg_loss).mean()from scratch: draft.md §2 (Word embeddings): SkipGramNeg
- 1
sg=1skip-gram objective: (target, context) pos pairs scored by pos_score - 2
negative=5neg: (B, k) sampled negatives and the neg_score / neg_loss term - 3
vector_size=100d_embed in nn.Embedding(vocab_size, d_embed) - 4
the two internal weight tables (wv / syn1neg)self.in_emb (target) and self.out_emb (context/negative) - 5
internal sigmoid log-loss trainingpos_loss + neg_loss = binary cross-entropy on sigmoid scores - 6
model.wv (final vectors)keeping only in_emb as the final embedding
What the one call hides
- Negative-sampling distribution is unigram frequency to the 0.75 power (ns_exponent default), not uniform — the scratch snippet never says how neg is drawn
- Frequent-word subsampling (sample~1e-3) randomly drops very common words; the scratch class has no such filter
- Vocabulary building, min_count pruning, and (target, context) pair generation over the window — the scratch forward assumes those tensors already exist
- Learning-rate decay from alpha to min_alpha across epochs, and the Cython-optimized multi-threaded training loop
- Returns only the input-side vectors (model.wv) by default, hiding that there were two tables (wv and syn1neg)
- Gotcha: min_count defaults to 5, so on a small corpus most of your vocabulary silently vanishes unless you set min_count=1
- Gotcha: sg defaults to 0 (CBOW), not skip-gram — forgetting sg=1 trains a different model than the scratch code
- Gotcha: epochs defaults to 5; on a tiny corpus that is far too few for analogy geometry to emerge
- Gotcha: king-man+woman≈queen needs a large corpus and tuned hyperparameters; it will not appear on a toy sentence list
For real embeddings you download pretrained GloVe/fastText or let your end-to-end model learn them; gensim is the historical reference to reproduce word2vec, and the scratch SkipGramNeg exists only to show .train() is sigmoid binary-cross-entropy over two embedding tables with sampled negatives.
On the job: You load pretrained vectors or an nn.Embedding learned inside your model; if you ever train word2vec you call gensim and tune window/negative/min_count — you do not hand-write the SGNS loss.
The two-embedding-tables structure is structural: in_emb is the "what word am I right now" representation, out_emb is the "what word should I predict" representation. You typically only keep in_emb as the final embedding.
For modern usage, you almost never train word embeddings from scratch. You either use pre-trained ones (GloVe, fastText vectors are downloadable in seconds) or you let your end-to-end model (BERT, GPT) learn them as part of the architecture. The pre-transformer word-embedding chapter ends here. The structural intuitions remain useful.
FIG 14.3.3
Encoder-decoder seq2seq, recap
another chapter already introduced the A two-part design where one half squeezes the input into a compact summary and the other half expands it into the output.Full glossary →. Quick recap because the rest of this chapter modifies it.
The encoder is an RNN (typically GRU or LSTM, often Reading a sequence both forward and backward so each spot has context from both sides.Full glossary →) that reads the source sequence and produces a A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary → at every position. The "context" passed to the decoder is, in the simplest A design that reads one sequence and writes out another, like turning a sentence into its translation.Full glossary →, just the encoder's final hidden state.
The decoder is another RNN that starts from the encoder's context as its initial hidden state and generates the target sequence one A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → at a time. At training time, it is teacher-forced: it sees the ground-truth previous token at each step. At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → time, it sees its own previous prediction.
class Seq2Seq(nn.Module):
def __init__(self, src_vocab: int, tgt_vocab: int, d: int = 256):
super().__init__()
self.src_emb = nn.Embedding(src_vocab, d)
self.tgt_emb = nn.Embedding(tgt_vocab, d)
self.encoder = nn.GRU(d, d, batch_first=True)
self.decoder = nn.GRU(d, d, batch_first=True)
self.head = nn.Linear(d, tgt_vocab)
def forward(self, src_ids, tgt_ids):
_, h_enc = self.encoder(self.src_emb(src_ids))
out, _ = self.decoder(self.tgt_emb(tgt_ids), h_enc)
return self.head(out)This is the architecture Bahdanau et al. 2014 started from. They identified a specific failure mode: when the source sentence is long (>20 tokens or so), the single final hidden state cannot carry all the information the decoder needs. Translation quality degrades sharply with source length. The next section is the fix.
FIG 14.3.4
The bottleneck and the fix
The problem with vanilla A design that reads one sequence and writes out another, like turning a sentence into its translation.Full glossary →: a single fixed-size A single bundle of numbers meant to summarize a whole input sequence.Full glossary → is asked to encode an arbitrary-length source sentence. For short sources, fine. For long sources, the context vector becomes a A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary →.
Bahdanau, Cho, Bengio 2014 ("Neural Machine Translation by Jointly Learning to Align and Translate") changed the framing. Instead of compressing the source into one vector and giving that to the decoder, keep all the encoder's hidden states and let the decoder, at every step, choose which encoder states to look at. The choice is a learned soft selection: at each decoder step, compute a A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → for each source position, A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → them, and take a weighted average of the encoder hidden states.
Jay Alammar's 01-explorables/jalammar-visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention §lets-pay-attention has the cleanest visualisation of this: the encoder produces hidden states, the decoder produces its own hidden states, and at each decoder step a distribution over source positions is computed and used to mix the encoder states into a position-specific context vector.
That mixing operation is A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →. Everything after this in the chapter (and most of another chapter) is variations on it.
FIG 14.3.5
Bahdanau attention (additive)
The first A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → mechanism (Bahdanau et al. 2014) computes alignment scores between the decoder's current A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary → and each encoder hidden state using an additive function:
where are learned matrices and is a learned vector. The score is a function of the concatenation of the two states, projected through a tanh nonlinearity, then projected down to a scalar.
The scores are softmaxed over source positions to get attention weights:
The A single bundle of numbers meant to summarize a whole input sequence.Full glossary → at decoder step is the weighted average of encoder hidden states:
The decoder uses along with its own hidden state to produce the next output. Concretely, the decoder input becomes , and the output projection sees .
Bahdanau (additive) attention — no library equivalent
DL glue# There is no library call equal to the scratch block below.
# nn.MultiheadAttention is DOT-PRODUCT attention (QK^T/sqrt d), not additive:
mha = nn.MultiheadAttention(embed_dim=d, num_heads=1, batch_first=True)
ctx, w = mha(query=s.unsqueeze(1), key=h, value=h) # different math: no tanh, no vclass BahdanauAttention(nn.Module):
def __init__(self, encoder_hidden, decoder_hidden):
super().__init__()
self.W = nn.Linear(decoder_hidden, decoder_hidden, bias=False)
self.U = nn.Linear(encoder_hidden, decoder_hidden, bias=False)
self.v = nn.Linear(decoder_hidden, 1, bias=False)
def forward(self, s, h): # s: (B, dec_h); h: (B, T_src, enc_h)
s_exp = s.unsqueeze(1) # (B, 1, dec_h)
scores = self.v(torch.tanh(self.W(s_exp) + self.U(h))).squeeze(-1) # (B, T_src)
weights = F.softmax(scores, dim=-1)
context = torch.bmm(weights.unsqueeze(1), h).squeeze(1) # (B, enc_h)
return context, weightsfrom scratch: lab/solution.py: BahdanauAttention.forward
- 1
nn.Linear projections W_q/W_k inside MultiheadAttentionself.W (on decoder state) and self.U (on encoder states) - 2
softmax over the key/source axisweights = F.softmax(scores, dim=-1) over T_src - 3
weighted sum of valuescontext = torch.bmm(weights.unsqueeze(1), h).squeeze(1) - 4
(NO library equivalent)the additive score v^T tanh(W s + U h) — a learned MLP scorer, not a dot product - 5
per-position attention weights for a heatmapweights returned per source position
What the one call hides
- nn.MultiheadAttention scores with QK^T/sqrt(d), so it cannot reproduce the tanh+v additive scorer at all — the easy library call is the WRONG mechanism (Luong, not Bahdanau)
- The scratch v^T tanh(W s + U h) lets encoder_hidden differ from decoder_hidden (here 2*hidden vs hidden) without an extra projection; MultiheadAttention assumes one embed_dim
- Additive attention has three learned matrices (W, U, v) vs dot-product's projections only — different parameter count and inductive bias
- PyTorch's softmax is numerically stable via internal logsumexp; the scratch code leans on F.softmax for that
- Gotcha: Reaching for nn.MultiheadAttention to 'replace' this silently swaps additive for multiplicative attention — it runs and trains but is a different model
- Gotcha: encoder_hidden is 2*decoder_hidden here (bidirectional encoder), so any drop-in library layer needs an explicit projection you must add yourself
- Gotcha: MultiheadAttention expects (query, key, value) and its mask / key_padding_mask conventions differ from a hand-rolled softmax mask
- Gotcha: averaging across heads in MultiheadAttention muddies the clean single-head alignment heatmap Bahdanau gives you
There is no production library call for additive attention because the field moved to dot-product (Luong/Vaswani); you build Bahdanau from scratch once to understand the historical alignment idea and read attention heatmaps, then in any real job you use scaled dot-product / nn.MultiheadAttention instead.
On the job: You hand-write a custom attention/scorer module like this when a model genuinely needs a non-dot-product alignment; in practice that is rare and you reach for nn.MultiheadAttention or F.scaled_dot_product_attention.
The "additive" name comes from the fact that the scoring function adds the projected query and key inside the tanh. This is in contrast to "multiplicative" attention, which takes a dot product. Additive attention was first because Bahdanau's group came from a recurrent-network background where the natural compositional operation was concatenation-then-linear. The multiplicative form turned out to be cheaper and equally accurate, and it is what won.
FIG 14.3.6
Luong attention (multiplicative)
Luong, Pham, Manning 2015 ("Effective Approaches to A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →-based Neural Machine Translation") proposed two simplifications.
First, replace the additive scoring function with a multiplicative one:
The dot product is cheaper than the additive form (one matmul instead of two MLPs) and trains comparably well. This is the operation that became scaled dot-product attention in Vaswani 2017.
Second, change when the attention is computed. Bahdanau used the previous decoder state to attend over encoder states, then combined the context with the previous-A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → to compute the new decoder state . Luong's "global" formulation computed first (from the previous decoder state and the new input token) and then used to attend over encoder states. The difference is small in practice but the Luong order is cleaner.
class LuongAttention(nn.Module):
def __init__(self, d: int, score_type: str = "general"):
super().__init__()
self.score_type = score_type
if score_type == "general":
self.W = nn.Linear(d, d, bias=False)
def forward(self, s: torch.Tensor, h: torch.Tensor) -> tuple:
# s: (B, d). h: (B, T_src, d).
if self.score_type == "dot":
scores = torch.bmm(s.unsqueeze(1), h.transpose(1, 2)).squeeze(1)
else: # general
scores = torch.bmm(self.W(s).unsqueeze(1), h.transpose(1, 2)).squeeze(1)
weights = torch.softmax(scores, dim=-1)
context = torch.bmm(weights.unsqueeze(1), h).squeeze(1)
return context, weightsFIG 14.3.7
Query, key, value: the generalised vocabulary
Bahdanau and Luong used different vocabularies, but the operations were structurally the same: compute compatibility scores between a "thing you're querying with" and "things you're querying over", A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → the scores, take a weighted sum of "values associated with each queried thing".
The unifying The fixed set of all chunks a model is allowed to read or produce.Full glossary → that won (Vaswani et al. 2017, retroactively applied to all of A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →) is:
- Query (Q): what the current step is asking
- Key (K): what each source position offers as a matching One piece of information about an example that the model looks at when making a guess.Full glossary →
- Value (V): what each source position contributes if matched
The attention operation:
In Bahdanau / Luong attention, Q is the decoder A number (or short list of numbers) the model keeps rewriting as it reads through a sequence, its running memory.Full glossary →, K and V are both the encoder hidden states (often identical). In A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary → (next section), Q, K, V are all linear projections of the same input. In transformer cross-attention, Q comes from the decoder and K, V come from the encoder. Same equation, different sources.
def scaled_dot_product_attention(Q, K, V, mask=None):
"""Q: (B, T_q, d_k). K, V: (B, T_k, d_k). mask: optional (T_q, T_k) bool."""
d_k = Q.size(-1)
scores = Q @ K.transpose(-2, -1) / (d_k ** 0.5)
if mask is not None:
scores = scores.masked_fill(mask, float("-inf"))
weights = torch.softmax(scores, dim=-1)
return weights @ V, weightsThis is the operation. Everything in another chapter is variations on it: stacking multiple heads, applying it to itself, masking it for causality. Internalise it now.
FIG 14.3.8
Self-attention as a generalisation
This is the moment the field pivots. A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary → is the generalisation of Bahdanau-style A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → where Q, K, and V all come from the same sequence. Every position attends to every other position in its own sequence, in parallel, with no recurrence.
The three properties that mattered, named only briefly here because they get earned in another chapter:
- No recurrence. One matmul per layer, parallelisable across all positions.
- Constant path length. Position can attend to position directly regardless of . No vanishing-A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary →-through-sequence-depth.
- Permutation equivariant. Self-attention has no notion of order on its own; you bolt positional embeddings on as a separate step (another chapter covers the full menu: absolute, sinusoidal, A way of telling a model where each token sits by twisting its number bundle a little more for each later position.Full glossary →).
The first self-attention layers in NLP were used as a refinement on top of RNN outputs (Cheng, Dong, Lapata 2016, "Long Short-Term Memory-Networks for Machine Reading"). Vaswani et al. 2017 ("Attention Is All You Need") replaced the RNN entirely. The latter is what stuck.
The full math (scaled dot-product, multi-head, A block that stops a model from peeking at later positions, so it only sees what came before.Full glossary →, batched implementation, shape-by-shape debug walkthrough) is derived from scratch in Ch 15 §3. The PyTorch class with proper multi-head reshaping lives there too. Here, the only thing to internalise is the equation in its barest form:
This is the operation another chapter builds the entire transformer architecture out of. The next chapter is essentially "now multiply this self-attention block by 12, stack with FFNs and LayerNorms, and train on text".
FIG 14.3.9
Teacher forcing and exposure bias
A subtlety that bites everyone who trains A design that reads one sequence and writes out another, like turning a sentence into its translation.Full glossary → for the first time.
At training time, the decoder is fed the ground-truth previous A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → at each step. This is called During training, feeding a sequence model the correct previous answer instead of its own guess so it learns faster.Full glossary →. It makes training fast and stable: the decoder always sees a correct context, so A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → signal is clean, and the loss is computed per-position independently.
At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → time, the decoder is fed its own previous prediction. If the model makes a mistake at position 3, that mistake feeds into the input at position 4, which compounds. By position 30, the model is operating in a regime it was never trained on (its own error-corrupted history). This is The gap where a model trains on correct previous answers but at run time must lean on its own guesses, so an early slip cascades.Full glossary →.
Fixes are heuristic:
- Scheduled sampling (Bengio et al. 2015). During training, with A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → feed the model its own prediction instead of the The correct, human-given answer for a piece of data, used to judge the model's guess.Full glossary →. Anneal from 0 toward 1 over training. Mostly works, mildly debated.
- A smarter way to build a sentence that keeps the few most promising options alive at once instead of committing to one.Full glossary → (next section). Reduces the impact of a single bad prediction by maintaining multiple candidates.
- Train-with-noise tricks (Sample of decoder inputs from the model's own distribution). Various flavours.
In practice, large modern models (transformers) ignore exposure bias mostly because they are large enough that the discrepancy between teacher-forced training and free-run inference is small. The problem is most acute for small RNN-based seq2seq systems.
FIG 14.3.10
Beam search
Building a sentence by always grabbing the single most likely next word at each step, never looking back.Full glossary → takes the argmax at each step. It is fast and often wrong: a locally-optimal first A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → can lock you into a globally bad sequence. A smarter way to build a sentence that keeps the few most promising options alive at once instead of committing to one.Full glossary → keeps the top- candidate sequences at every step and only commits at the end.
The algorithm:
- Start with copies of the start token, each with score 0.
- At each step, for each of the candidates, expand it by every possible next token. You now have candidates.
- Score each: existing-score + log-prob of the new token.
- Keep the top by score.
- Stop when all candidates have emitted end-of-sequence.
import math
@torch.no_grad()
def beam_search(model, start_id: int, end_id: int, max_len: int = 50, k: int = 4):
beams = [([start_id], 0.0)]
finished = []
for _ in range(max_len):
new_beams = []
for seq, score in beams:
if seq[-1] == end_id:
finished.append((seq, score))
continue
idx = torch.tensor([seq])
logits = model(idx)[:, -1, :]
log_probs = torch.log_softmax(logits, dim=-1).squeeze(0)
top_logp, top_idx = log_probs.topk(k)
for lp, tid in zip(top_logp.tolist(), top_idx.tolist()):
new_beams.append((seq + [tid], score + lp))
new_beams.sort(key=lambda x: x[1], reverse=True)
beams = new_beams[:k]
if not beams: break
finished.extend(beams)
# Length-normalised re-ranking (otherwise beam search prefers short sequences)
finished.sort(key=lambda x: x[1] / len(x[0]), reverse=True)
return finished[0]Beam search is rarely used for open-ended language generation today (it produces too deterministic and repetitive outputs). It remains standard for machine translation, summarisation, and other tasks where there is a "correct" answer the model should converge toward.
FIG 14.3.11
The historical pivot point
From Bahdanau (2014) to Vaswani (2017), the field built A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → on top of RNN backbones. The encoder was an RNN, the decoder was an RNN, and attention was a bolt-on between them. The RNN did the sequence modeling; attention did the cross-sequence alignment.
Vaswani et al. 2017's insight ("Attention Is All You Need") was that you could remove the RNN entirely. If A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary → can mix information across all positions in one matmul, you do not need recurrence to model sequential dependencies. You can build the entire encoder out of stacked self-attention + feedforward layers, the entire decoder out of stacked self-attention + cross-attention + feedforward layers, and train the whole thing in parallel across positions.
The payoffs:
- Training parallelises across sequence positions, so per-step wall-clock drops by orders of magnitude on long sequences.
- No When the learning signal fades to almost nothing as it travels back through a deep model, so the early layers barely change.Full glossary → through depth-of-sequence.
- The model can attend to any position directly. This turned out to encode dependencies BiLSTM models could only approximate.
The cost: memory and compute in the sequence length, versus for an RNN. For sequences up to a few thousand tokens (the regime where most NLP lives), this is a win. For very long sequences (DNA, code, long documents), specialised attention variants (Longformer, BigBird, FlashAttention) and state-space models (Mamba) reclaim some of the linear-time territory.
This is the chapter's exit door. another chapter picks up here: same attention math, no RNN, full transformer.
FIG 14.3.12
What attention looks like, visualised
Bahdanau A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →'s A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → matrix has a useful interpretation: it is a soft alignment between target positions and source positions. If you display it as a heatmap with source positions on the x-axis and target positions on the y-axis, a translation model trained well shows a near-diagonal pattern with some scrambling.
Jay Alammar's 01-explorables/jalammar-visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention §alignment-visualization shows this for English-to-French. When the model emits "économique" it attends heavily to "economic" in the source. When it emits "européenne" it attends to "European". The alignment is interpretable: you can audit a translation model by inspecting the attention weights.
This interpretability property partially transferred to transformers and partially did not. Transformer attention heads have many roles (positional, syntactic, semantic, copy-mechanism). Some are interpretable in the Bahdanau-alignment sense; many are not. The mech-interp literature (another chapter) is largely about figuring out what transformer attention heads actually do.
# Visualise attention weights for a trained Bahdanau model
import matplotlib.pyplot as plt
def plot_attention(weights: torch.Tensor, src_tokens: list[str], tgt_tokens: list[str]):
"""weights: (T_tgt, T_src)."""
fig, ax = plt.subplots(figsize=(len(src_tokens), len(tgt_tokens)))
ax.imshow(weights.cpu().numpy(), aspect="auto", cmap="viridis")
ax.set_xticks(range(len(src_tokens))); ax.set_xticklabels(src_tokens, rotation=45)
ax.set_yticks(range(len(tgt_tokens))); ax.set_yticklabels(tgt_tokens)
ax.set_xlabel("source"); ax.set_ylabel("target")
return figFIG 14.4 · Safety lens · this chapter
A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → introduced two safety-relevant phenomena that are now standard concerns in deployed NLP systems.
Attention as a leakage One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary →. The attention weights are differentiable functions of the model parameters and inputs. They expose which source tokens influence each output A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →. For machine translation this is harmless and even useful (interpretable alignment, easy debugging). For systems that ingest sensitive text and produce summaries or translations, the attention weights themselves can leak information. Two known cases. First, an attacker who can observe attention weights can reconstruct properties of the input (membership-Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → attacks — guessing whether a specific sentence was in the The batch of examples the model actually studies and learns from.Full glossary → — against translation models, Hisamoto et al. 2020). Second, in retrieval-augmented systems where the model attends over retrieved documents, the attention pattern reveals which documents were considered. If those documents contain access-controlled or PII material, the pattern itself is a side channel. See 26-pentest-redteam/genai-owasp-org-llm-top-10 §LLM06-sensitive-info-disclosure.
Adversarial token insertion. Once attention exists, an attacker can craft inputs that hijack it. A specific failure mode demonstrated repeatedly: insert a "high-salience" token into the source that pulls attention A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → away from the rest of the input, causing the model to ignore semantic content and copy or echo the adversarial token instead. This is the structural ancestor of modern Sneaking extra instructions into an input that the model can't tell apart from the real task, so it follows yours too.Full glossary →. The mechanism is the same: attention is a learned soft-routing operation, and inputs that look anomalous in the A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → space can dominate the routing. See 24-founder-blogs/willison-simonwillison-net-2025-oct-21-unseeable-prompt-injections for a recent case of zero-width-Unicode-character prompt injections that hijack attention in current LLMs. The defense requires sanitisation at the tokenizer level and during attention computation (some research adds entropy-floor regularisation to prevent attention from collapsing to a single token, but no defense is widely deployed).
Word embeddings encode A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary →. Word2vec, GloVe, and fastText embeddings learned on web-scale text encode the statistical regularities of that text, including its biases. The standard demonstration (Bolukbasi et al. 2016, Caliskan et al. 2017): on a trained word2vec model, programmer - man + woman ≈ homemaker. The vector geometry encodes gendered occupational stereotypes because the training text encodes them. When you use pre-trained embeddings as inputs to a downstream classifier, those biases propagate. The detection is the WEAT test (Word Embedding Association Test); the mitigation is harder and involves debiasing the embedding space along identified bias subspaces or, more directly, training on more curated text. This carries through to today's LLMs: token embeddings inside transformer language models are still learned the same way and exhibit the same statistical regularities. See 05-safety/aisafetybook-index §representational-harms for the policy framing.
Habits to adopt when you write attention code:
- Sanitise inputs before tokenisation. Strip zero-width characters (invisible Unicode code points that carry no glyph but still tokenize) and homoglyph substitutions (look-alike characters, e.g. Cyrillic
аfor Latina), along with unusual control codes. These bypass standard input filters and influence attention disproportionately. - Test attention When part of a model stops reacting to input because its output is already pushed to a hard limit.Full glossary → on adversarial inputs. Run a few attack strings ("ignore previous instructions", "[INST]" with mismatched brackets) through your model and inspect the attention distributions. If a single adversarial token captures >50% of attention mass at a critical layer, you have a hijacking vulnerability.
- Audit your word embeddings or token embeddings for known bias axes before deploying. A 20-line WEAT test takes 5 minutes and detects the most-cited bias patterns.
FIG 14.5 · Under the hood
The library call, and the lines it hides
You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.
F.scaled_dot_product_attention vs. from scratch
DL primitive# Q: (B, T_q, d_k) K,V: (B, T_k, d_v)
# attn_mask: bool True = KEEP a position (opposite of the scratch convention)
out = F.scaled_dot_product_attention(Q, K, V, attn_mask=None) # (B, T_q, d_v)def scaled_dot_product_attention(Q, K, V, mask=None):
"""Q: (B, T_q, d_k). K, V: (B, T_k, d_k). mask: optional (T_q, T_k) bool."""
d_k = Q.size(-1)
scores = Q @ K.transpose(-2, -1) / (d_k ** 0.5)
if mask is not None:
scores = scores.masked_fill(mask, float("-inf"))
weights = torch.softmax(scores, dim=-1)
return weights @ V, weightsfrom scratch: draft.md §7 (Query, key, value): scaled_dot_product_attention
- 1
F.scaled_dot_product_attention(Q, K, V)the whole function body: score, scale, softmax, weighted-sum - 2
the internal QK^T / sqrt(d_k)scores = Q @ K.transpose(-2, -1) / (d_k ** 0.5) - 3
the internal softmax over keysweights = torch.softmax(scores, dim=-1) - 4
the internal weights @ Vreturn weights @ V - 5
attn_mask= (bool True KEEPS a position)scores.masked_fill(mask, -inf) where True MASKS a position — inverted boolean convention - 6
scale= (defaults to 1/sqrt(d_k))the hardcoded / (d_k ** 0.5) divisor
What the one call hides
- The default scale is 1/sqrt(last dim of Q); you never see the sqrt(d_k) the scratch version spells out, and overriding it requires the scale= kwarg
- Boolean attn_mask is INVERTED vs the scratch masked_fill: library True means keep, scratch True means mask out
- Picks a fused/flash kernel when math, dtype, and contiguity allow it; otherwise silently falls back to an eager path — same result, very different speed/memory
- Applies dropout_p to the attention weights before the V matmul if you pass it; the scratch version has no dropout
- is_causal=True builds the triangular mask internally; the scratch version makes you construct and pass the mask yourself
- Returns only the output, never the attention weights; the scratch version hands back weights for inspection/heatmaps
- Gotcha: is_causal=True together with a non-None attn_mask is an error; pick one
- Gotcha: A float attn_mask is ADDED to the scores (use -inf to block) but a bool attn_mask is keep/drop — mixing the two silently changes the math
- Gotcha: There is no weights output, so you cannot draw the alignment heatmap without re-implementing the softmax manually
- Gotcha: Fused kernels only fire for supported dtypes/shapes; a stray non-contiguous tensor or odd head dim drops you to the slow path with no warning
Prefer F.scaled_dot_product_attention in production for the fused/flash kernel; keep the four-line scratch version only to see it is literally softmax(QK^T/sqrt(d_k))V and to get the attention weights back for a heatmap.
On the job: You wire the right Q/K/V projections, masks (causal/padding), and head reshapes around the call — the attention op itself is the library's; you almost never reimplement the inner softmax(QK^T)V at work.
HuggingFace BPE trainer vs. from scratch
Classical MLtok = Tokenizer(BPE(unk_token="[UNK]"))
trainer = BpeTrainer(vocab_size=1000, special_tokens=["[UNK]"])
tok.train_from_iterator([text], trainer)
ids = tok.encode("tokenization").idsdef get_pair_counts(seq):
return Counter(zip(seq, seq[1:]))
def merge(seq, pair, new_id):
out, i = [], 0
while i < len(seq):
if i < len(seq) - 1 and (seq[i], seq[i+1]) == pair:
out.append(new_id); i += 2
else:
out.append(seq[i]); i += 1
return out
def train_bpe(text, target_vocab=1000):
seq = list(text.encode("utf-8"))
merges, next_id = {}, 256
while next_id < target_vocab:
pairs = get_pair_counts(seq)
if not pairs: break
best = pairs.most_common(1)[0][0]
seq = merge(seq, best, next_id)
merges[best] = next_id
next_id += 1
return seq, mergesfrom scratch: draft.md §1 (Tokenization): train_bpe / get_pair_counts / merge
- 1
BpeTrainer(vocab_size=1000)the while next_id < target_vocab merge loop - 2
trainer's most-frequent-pair selectionbest = get_pair_counts(seq).most_common(1)[0][0] - 3
applying a learned mergemerge(seq, best, next_id) - 4
the learned merges table inside the Tokenizermerges[best] = next_id - 5
byte-level base alphabet (256 bytes)seq = list(text.encode('utf-8')); next_id starts at 256 - 6
tok.encode(...).idsre-applying stored merges in learned order to new text (not shown in train_bpe)
What the one call hides
- Regex pre-tokenization (the GPT-2 split pattern) so merges never cross word/punctuation boundaries — the scratch version merges across the whole byte stream, spaces included
- Special tokens ([UNK], [PAD], [CLS]/[BOS]/[EOS]) registered at fixed ids and protected from being split
- An incremental pair-count update instead of recounting every pair from scratch each iteration (the scratch loop is O(N) per merge)
- Storing the merge ORDER so encode() can replay merges deterministically on unseen text; the scratch train_bpe returns merges but never shows the replay
- Byte-level vs char-level alphabet, normalization (NFC/lowercase), and dropout-BPE — all configurable, none in the scratch code
- Gotcha: Without the regex pre-tokenizer the scratch merges happily glue a word to the following space, producing tokens GPT-2's tokenizer would never create
- Gotcha: vocab_size counts special tokens and the base alphabet, so the effective number of learned merges is smaller than the number you pass
- Gotcha: A leading space is part of the token (' cat' vs 'cat' are different ids); beginners debugging prompts miss this
- Gotcha: train_bpe returns the merged sequence and a merges dict but no encoder — you cannot tokenize new strings until you also implement the merge-replay step
In production, prefer HuggingFace tokenizers / tiktoken / SentencePiece (Rust-fast, with the regex split and special tokens that matter); the 20-line scratch train_bpe exists only to prove BPE is just 'repeatedly merge the most frequent adjacent pair'.
On the job: You pick a vocab size, special-token set, and normalizer/pre-tokenizer, train on your corpus, and load the saved tokenizer.json — you do not hand-write the merge loop.
FIG 14.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A character tokenizer for Shakespeare, then a tiny byte-pair encoder you train yourself and watch merge th into one token.
- A character-level GRU language model trained on ~80KB of Shakespeare, that you then sample from at different temperatures.
- The attention operation from scratch, three ways: Bahdanau additive, Luong multiplicative, and the unified scaled dot-product form, each cross-checked against a torch reference.
- A deliberate failure: dot-product attention at large d_k collapsing to a one-hot, then the one-line / sqrt(d_k) fix that rescues it.
~5 min on CPU · 109 cells · 12 checked exercises · runs in Colab
FIG 14.7 · Going further
01-explorables/jalammar-visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attentionthe canonical pre-transformer attention explainer. Read once before another chapter.
18-lilian-weng/2018-06-24-attentionLilian Weng's comprehensive attention survey. Covers everything from Bahdanau to self-attention to the variants we did not have room for.
16-d2l-sections/chapter_attention-mechanisms-and-transformers__bahdanau-attentiond2l.ai with full code. Pair with this chapter.
01-explorables/jalammar-illustrated-word2vecthe cleanest visualisation of skip-gram with negative sampling. The animated negative-sampling figure is worth a careful read.
04-stanford/cs336-lecture_01Stanford's 2024 tokenization lecture. Detailed walk-through of BPE, byte-level, and SentencePiece variants.
13-fastbook/12_nlp_divefastbook's NLP chapter. Practical pre-transformer NLP from end to end.
24-founder-blogs/raschka-understanding-encoder-and-decoderSebastian Raschka's careful re-derivation of the seq2seq + attention math. Pedagogically clean.
08-geron-notebooks/16_nlp_with_rnns_and_attentionGéron's chapter with TF code (translate to PyTorch as you read).
FIG 14.8 · What this enables
Chapters you can now read, with the connecting idea written out.
This chapter's last sections (self-attention, Q-K-V framework) are the direct setup. another chapter picks up at the moment Vaswani et al. dropped the RNN.
Cross-attention is the same operation that lets a vision transformer condition on text or vice versa. The Bahdanau cross-attention you built here is the pre-transformer ancestor of CLIP's projection layer.
Modern transformer interpretability builds on the same alignment-visualisation intuition Bahdanau attention gave us. Heads that "do X" are heads whose attention pattern looks like X.
FIG 14.9 · 29 sources
- 01-explorables/distill-augmented-rnns
- 01-explorables/jalammar-illustrated-gpt2
- 01-explorables/jalammar-illustrated-transformer
- 01-explorables/jalammar-illustrated-word2vec
- 01-explorables/jalammar-visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention
- 01-explorables/unknown-karpathy-minbpe
- 04-stanford/cs336-lecture_01
- 05-safety/aisafetybook-index
- 08-geron-notebooks/16_nlp_with_rnns_and_attention
- 13-fastbook/10_nlp
- 13-fastbook/12_nlp_dive
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__attention-pooling
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__attention-scoring-functions
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__bahdanau-attention
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__queries-keys-values
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__self-attention-and-positional-encoding
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__transformer
- 16-d2l-sections/chapter_natural-language-processing-pretraining__glove
- 16-d2l-sections/chapter_natural-language-processing-pretraining__subword-embedding
- 16-d2l-sections/chapter_natural-language-processing-pretraining__word2vec
- 16-d2l-sections/chapter_recurrent-modern__beam-search
- 16-d2l-sections/chapter_recurrent-modern__machine-translation-and-dataset
- 16-d2l-sections/chapter_recurrent-modern__seq2seq
- 18-lilian-weng/2018-06-24-attention
- 18-lilian-weng/2020-04-07-the-transformer-family
- 24-founder-blogs/raschka-understanding-encoder-and-decoder
- 24-founder-blogs/raschka-understanding-large-language-models
- 24-founder-blogs/willison-simonwillison-net-2025-oct-21-unseeable-prompt-injections
- 26-pentest-redteam/genai-owasp-org-llm-top-10