Ch. 14

NLP with RNNs + Attention

Word embeddings, encoder-decoder, Bahdanau → Luong → self-attention. The historical pivot point.

word2vecBahdanauself-attention

FIG 14 · Explainer video


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 , 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 "". By the end of this chapter you will have built word embeddings, an , additive attention, multiplicative attention, and . 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

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. 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 per word. Simple, but the explodes (English has hundreds of thousands of distinct words once you count inflections and proper nouns), 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 (, 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 (tokenizationtoken, ization). Pre-trained models all use some variant: GPT uses byte-level BPE, BERT uses WordPiece, T5 and Llama use SentencePiece.

Python
# 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, merges

This 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 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 space. The canonical demonstration is the analogy structure: kingman+womanqueen\text{king} - \text{man} + \text{woman} \approx \text{queen} 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 nn consecutive characters, so where at n=3n=3 contributes <wh, whe, her, ere, re>. Handles 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 ML
LIBRARY
model = 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"])
FROM SCRATCH
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. 1sg=1 skip-gram objective: (target, context) pos pairs scored by pos_score
  2. 2negative=5 neg: (B, k) sampled negatives and the neg_score / neg_loss term
  3. 3vector_size=100 d_embed in nn.Embedding(vocab_size, d_embed)
  4. 4the two internal weight tables (wv / syn1neg) self.in_emb (target) and self.out_emb (context/negative)
  5. 5internal sigmoid log-loss training pos_loss + neg_loss = binary cross-entropy on sigmoid scores
  6. 6model.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 . Quick recap because the rest of this chapter modifies it.

The encoder is an RNN (typically GRU or LSTM, often ) that reads the source sequence and produces a at every position. The "context" passed to the decoder is, in the simplest , 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 at a time. At training time, it is teacher-forced: it sees the ground-truth previous token at each step. At time, it sees its own previous prediction.

Python
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 single fixed-size is asked to encode an arbitrary-length source sentence. For short sources, fine. For long sources, the context vector becomes a .

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 for each source position, 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 TsrcT_{\text{src}} 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 . Everything after this in the chapter (and most of another chapter) is variations on it.

FIG 14.3.5

Bahdanau attention (additive)

The first mechanism (Bahdanau et al. 2014) computes alignment scores between the decoder's current st1s_{t-1} and each encoder hidden state hih_i using an additive function:

et,i=vaTtanh(Wast1+Uahi)e_{t,i} = v_a^T \tanh(W_a s_{t-1} + U_a h_i)

where Wa,UaW_a, U_a are learned matrices and vav_a 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:

αt,i=exp(et,i)j=1Tsrcexp(et,j)\alpha_{t,i} = \frac{\exp(e_{t,i})}{\sum_{j=1}^{T_{\text{src}}} \exp(e_{t,j})}

The at decoder step tt is the weighted average of encoder hidden states:

ct=i=1Tsrcαt,ihic_t = \sum_{i=1}^{T_{\text{src}}} \alpha_{t,i} h_i

The decoder uses ctc_t along with its own hidden state to produce the next output. Concretely, the decoder input becomes [prev_token_embedding,ct][\text{prev\_token\_embedding}, c_t], and the output projection sees [st,ct][s_t, c_t].

Bahdanau (additive) attention — no library equivalent

DL glue
LIBRARY
# 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 v
FROM SCRATCH
class 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, weights

from scratch: lab/solution.py: BahdanauAttention.forward

  1. 1nn.Linear projections W_q/W_k inside MultiheadAttention self.W (on decoder state) and self.U (on encoder states)
  2. 2softmax over the key/source axis weights = F.softmax(scores, dim=-1) over T_src
  3. 3weighted sum of values context = torch.bmm(weights.unsqueeze(1), h).squeeze(1)
  4. 4(NO library equivalent) the additive score v^T tanh(W s + U h) — a learned MLP scorer, not a dot product
  5. 5per-position attention weights for a heatmap weights 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 -based Neural Machine Translation") proposed two simplifications.

First, replace the additive scoring function with a multiplicative one:

et,i=st1TWhi(general)e_{t,i} = s_{t-1}^T W h_i \quad \text{(general)} et,i=st1Thi(dot, if encoder and decoder dims match)e_{t,i} = s_{t-1}^T h_i \quad \text{(dot, if encoder and decoder dims match)}

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 st1s_{t-1} to attend over encoder states, then combined the context with the previous- to compute the new decoder state sts_t. Luong's "global" formulation computed sts_t first (from the previous decoder state and the new input token) and then used sts_t to attend over encoder states. The difference is small in practice but the Luong order is cleaner.

Python
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, weights

FIG 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", the scores, take a weighted sum of "values associated with each queried thing".

The unifying that won (Vaswani et al. 2017, retroactively applied to all of ) is:

  • Query (Q): what the current step is asking
  • Key (K): what each source position offers as a matching
  • Value (V): what each source position contributes if matched

The attention operation:

Attention(Q,K,V)=softmax ⁣(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{Q K^T}{\sqrt{d_k}}\right) V

In Bahdanau / Luong attention, Q is the decoder , K and V are both the encoder hidden states (often identical). In (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.

Python
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, weights

This 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. is the generalisation of Bahdanau-style 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 ii can attend to position jj directly regardless of ij|i-j|. No vanishing--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, ).

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, , 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:

SelfAttention(X)=softmax ⁣((XWq)(XWk)Tdk)(XWv)\text{SelfAttention}(X) = \text{softmax}\!\left(\frac{(XW_q)(XW_k)^T}{\sqrt{d_k}}\right) (XW_v)

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 for the first time.

At training time, the decoder is fed the ground-truth previous at each step. This is called . It makes training fast and stable: the decoder always sees a correct context, so signal is clean, and the loss is computed per-position independently.

At 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 .

Fixes are heuristic:

  • Scheduled sampling (Bengio et al. 2015). During training, with pp feed the model its own prediction instead of the . Anneal pp from 0 toward 1 over training. Mostly works, mildly debated.
  • (next section). Reduces the impact of a single bad prediction by maintaining multiple candidates.
  • Train-with-noise tricks (Sample p%p\% 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.

takes the argmax at each step. It is fast and often wrong: a locally-optimal first can lock you into a globally bad sequence. keeps the top-kk candidate sequences at every step and only commits at the end.

The algorithm:

  1. Start with kk copies of the start token, each with score 0.
  2. At each step, for each of the kk candidates, expand it by every possible next token. You now have kVk \cdot V candidates.
  3. Score each: existing-score + log-prob of the new token.
  4. Keep the top kk by score.
  5. Stop when all kk candidates have emitted end-of-sequence.
Python
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 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 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 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: O(L2)O(L^2) memory and compute in the sequence length, versus O(L)O(L) 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 's matrix αt,i\alpha_{t,i} 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.

Python
# 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 fig

FIG 14.4 · Safety lens · this chapter

introduced two safety-relevant phenomena that are now standard concerns in deployed NLP systems.

Attention as a leakage . The attention weights αt,i\alpha_{t,i} are differentiable functions of the model parameters and inputs. They expose which source tokens influence each output . 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- attacks — guessing whether a specific sentence was in the — 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 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 . The mechanism is the same: attention is a learned soft-routing operation, and inputs that look anomalous in the 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 . 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 Latin a), along with unusual control codes. These bypass standard input filters and influence attention disproportionately.
  • Test attention 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
LIBRARY
# 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)
FROM SCRATCH
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, weights

from scratch: draft.md §7 (Query, key, value): scaled_dot_product_attention

  1. 1F.scaled_dot_product_attention(Q, K, V) the whole function body: score, scale, softmax, weighted-sum
  2. 2the internal QK^T / sqrt(d_k) scores = Q @ K.transpose(-2, -1) / (d_k ** 0.5)
  3. 3the internal softmax over keys weights = torch.softmax(scores, dim=-1)
  4. 4the internal weights @ V return weights @ V
  5. 5attn_mask= (bool True KEEPS a position) scores.masked_fill(mask, -inf) where True MASKS a position — inverted boolean convention
  6. 6scale= (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 ML
LIBRARY
tok = Tokenizer(BPE(unk_token="[UNK]"))
trainer = BpeTrainer(vocab_size=1000, special_tokens=["[UNK]"])
tok.train_from_iterator([text], trainer)
ids = tok.encode("tokenization").ids
FROM SCRATCH
def 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, merges

from scratch: draft.md §1 (Tokenization): train_bpe / get_pair_counts / merge

  1. 1BpeTrainer(vocab_size=1000) the while next_id < target_vocab merge loop
  2. 2trainer's most-frequent-pair selection best = get_pair_counts(seq).most_common(1)[0][0]
  3. 3applying a learned merge merge(seq, best, next_id)
  4. 4the learned merges table inside the Tokenizer merges[best] = next_id
  5. 5byte-level base alphabet (256 bytes) seq = list(text.encode('utf-8')); next_id starts at 256
  6. 6tok.encode(...).ids re-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-attention

    the canonical pre-transformer attention explainer. Read once before another chapter.

  • 18-lilian-weng/2018-06-24-attention

    Lilian 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-attention

    d2l.ai with full code. Pair with this chapter.

  • 01-explorables/jalammar-illustrated-word2vec

    the cleanest visualisation of skip-gram with negative sampling. The animated negative-sampling figure is worth a careful read.

  • 04-stanford/cs336-lecture_01

    Stanford's 2024 tokenization lecture. Detailed walk-through of BPE, byte-level, and SentencePiece variants.

  • 13-fastbook/12_nlp_dive

    fastbook's NLP chapter. Practical pre-transformer NLP from end to end.

  • 24-founder-blogs/raschka-understanding-encoder-and-decoder

    Sebastian Raschka's careful re-derivation of the seq2seq + attention math. Pedagogically clean.

  • 08-geron-notebooks/16_nlp_with_rnns_and_attention

    Gé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
  1. 01-explorables/distill-augmented-rnns
  2. 01-explorables/jalammar-illustrated-gpt2
  3. 01-explorables/jalammar-illustrated-transformer
  4. 01-explorables/jalammar-illustrated-word2vec
  5. 01-explorables/jalammar-visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention
  6. 01-explorables/unknown-karpathy-minbpe
  7. 04-stanford/cs336-lecture_01
  8. 05-safety/aisafetybook-index
  9. 08-geron-notebooks/16_nlp_with_rnns_and_attention
  10. 13-fastbook/10_nlp
  11. 13-fastbook/12_nlp_dive
  12. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__attention-pooling
  13. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__attention-scoring-functions
  14. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__bahdanau-attention
  15. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__queries-keys-values
  16. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__self-attention-and-positional-encoding
  17. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__transformer
  18. 16-d2l-sections/chapter_natural-language-processing-pretraining__glove
  19. 16-d2l-sections/chapter_natural-language-processing-pretraining__subword-embedding
  20. 16-d2l-sections/chapter_natural-language-processing-pretraining__word2vec
  21. 16-d2l-sections/chapter_recurrent-modern__beam-search
  22. 16-d2l-sections/chapter_recurrent-modern__machine-translation-and-dataset
  23. 16-d2l-sections/chapter_recurrent-modern__seq2seq
  24. 18-lilian-weng/2018-06-24-attention
  25. 18-lilian-weng/2020-04-07-the-transformer-family
  26. 24-founder-blogs/raschka-understanding-encoder-and-decoder
  27. 24-founder-blogs/raschka-understanding-large-language-models
  28. 24-founder-blogs/willison-simonwillison-net-2025-oct-21-unseeable-prompt-injections
  29. 26-pentest-redteam/genai-owasp-org-llm-top-10