Attention, intuitively

Why attention beats recurrence for long-range dependencies, and the query-key-value mental model.

🧠 Module 1 8 min read Not started

Why this matters

Every modern LLM is "attention all the way down." Most engineers can recite "Q, K, V" without having a real picture of what attention is computing. This lesson installs that picture before we hit the formal Transformer architecture in Phase 2. Read this lesson once, do the lab, and you will never be confused by attention again.

Learning objectives

  1. Describe attention as a soft, differentiable dictionary lookup.
  2. Implement scaled dot-product attention from scratch.
  3. Reason about masking, padding, and the role of the sqrt(d_k) scaling.
  4. Distinguish self-attention from cross-attention from causal attention.
  5. Implement multi-head attention and explain why we need multiple heads.

1. The big idea β€” soft lookup

Imagine a Python dictionary:

python
db = {"apple": 5, "banana": 3, "cherry": 9}
db["banana"]  # 3 β€” exact key match

Attention is the same idea, but soft and differentiable:

  • The query "kind-of matches" each key.
  • We blend values according to how well the query matches each key.

Mathematically:

attention(Q, K, V) = softmax(Q K^T / sqrt(d_k)) V
  • Q: the queries (what I am looking for).
  • K: the keys (what each item advertises).
  • V: the values (the actual content to fetch).
  • softmax(Q K^T / sqrt(d_k)): a row of attention weights summing to 1 β€” the soft "lookup."

That is all of attention. Everything else is plumbing.


2. Why sqrt(d_k) scaling?

If Q and K are random vectors with unit-variance components, Q . K has variance d_k. As d_k grows, the dot product magnitudes grow β†’ the softmax becomes very sharp (mostly 0s and 1s) β†’ gradients through softmax vanish.

Dividing by sqrt(d_k) keeps the variance ~1 regardless of d_k, keeping the softmax in a "useful temperature" zone. Forget this and your transformer trains terribly.


3. Implementation in 12 lines

python
import torch, math
import torch.nn.functional as F

def attention(Q, K, V, mask=None):
    # Q, K, V: (B, T, d_k)
    d_k = Q.size(-1)
    scores = Q @ K.transpose(-2, -1) / math.sqrt(d_k)   # (B, T_q, T_k)
    if mask is not None:
        scores = scores.masked_fill(~mask, float("-inf"))
    weights = F.softmax(scores, dim=-1)                # (B, T_q, T_k)
    return weights @ V, weights                        # (B, T_q, d_v), weights for inspection

Try it:

python
B, T, d = 2, 5, 16
Q = K = V = torch.randn(B, T, d)        # self-attention: same source
out, w = attention(Q, K, V)
print(out.shape, w.sum(-1))             # (2, 5, 16), all ones

4. Self-attention vs cross-attention

  • Self-attention: Q, K, V are all derived from the same sequence. Used in the encoder of T5/BERT and in every layer of GPT-style models.
  • Cross-attention: Q comes from one sequence (e.g., decoder so far), K/V from another (e.g., encoder output). Used in seq2seq (T5, Whisper, NMT models) and in some adapters / multimodal models.

Same equation, different inputs.


5. Causal masking β€” the GPT secret

For autoregressive models, position t must not see positions t+1...T-1 (it is generating them, after all). Implement with a lower-triangular mask:

python
T = 5
causal_mask = torch.tril(torch.ones(T, T, dtype=torch.bool))     # True where allowed
# rows are queries, columns are keys
# row 0: only key 0
# row 1: keys 0,1
# row 2: keys 0,1,2 ...

Apply via masked_fill(~causal_mask, -inf). After softmax, future positions get weight 0.

This is the only algorithmic difference between BERT-style (bidirectional) and GPT-style (causal) transformers.

Padding mask

In a batch, sequences have different lengths; we pad the short ones. The padding tokens must also get attention weight 0:

mask = causal_mask & (key_positions != PAD)

6. Multi-head attention

Empirical observation: a single attention head learns to look at one kind of relationship (e.g., "the next noun"). Multiple heads attend to different kinds of relationships in parallel.

Mechanically, multi-head attention is:

1. Project x to Q, K, V each of shape (B, T, d). [Linear projections.]
2. Reshape to (B, T, h, d/h) -> (B, h, T, d/h).
3. Run scaled-dot-product per head -> (B, h, T, d/h).
4. Concatenate heads -> (B, T, d).
5. Output projection W_O.

In PyTorch:

python
import torch.nn as nn

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads, causal=True):
        super().__init__()
        assert d_model % n_heads == 0
        self.h = n_heads
        self.dk = d_model // n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.out = nn.Linear(d_model, d_model, bias=False)
        self.causal = causal

    def forward(self, x, mask=None):
        B, T, d = x.shape
        qkv = self.qkv(x).reshape(B, T, 3, self.h, self.dk).permute(2, 0, 3, 1, 4)
        Q, K, V = qkv[0], qkv[1], qkv[2]                  # (B, h, T, dk)
        scores = (Q @ K.transpose(-2, -1)) / (self.dk ** 0.5)
        if self.causal:
            cm = torch.tril(torch.ones(T, T, dtype=torch.bool, device=x.device))
            scores = scores.masked_fill(~cm, float("-inf"))
        if mask is not None:
            scores = scores.masked_fill(~mask[:, None, None, :], float("-inf"))
        w = scores.softmax(dim=-1)
        out = w @ V                                       # (B, h, T, dk)
        out = out.transpose(1, 2).reshape(B, T, d)        # concat heads
        return self.out(out)

That is ~30 lines. You will rebuild this from memory in interviews.

Why multiple heads?

  • Different heads capture different patterns (subject-verb agreement, coreference, syntactic role, semantic similarity).
  • Mathematically, multi-head attention with h heads of size d/h has the same parameter count as one head of size d but strictly more expressive (the projection split lets different subspaces specialise).
  • Modern wrinkle: at scale, many heads are redundant; GQA (Grouped-Query Attention) and MQA (Multi-Query Attention) share K/V across heads to save memory. Phase 2 covers it.

7. Attention is O(T^2) β€” the bottleneck

For sequence length T, the score matrix is (T, T) β€” quadratic in time and memory. That is why long contexts are expensive and why FlashAttention (re-orders the computation to use SRAM and avoid materialising the full TΓ—T matrix) is so important. We will return to this in Phase 5.

Variants that try to break the quadratic wall:

  • Sparse attention (Longformer, BigBird) β€” only compute attention to a subset of positions.
  • Linear attention (Performer, RWKV) β€” replace softmax with a kernel that factorises.
  • State-space models (Mamba) β€” drop attention entirely; Phase 6.

For 2026 production: flash + sliding window + GQA is the standard cocktail; full long-context now goes to 1M+ tokens (Gemini 2.x, Claude 4, Llama 4).


Hands-on lab (3 hours)

attention_lab.ipynb:

  1. Implement attention(Q, K, V, mask) from scratch as above.
  2. Generate Q, K, V with T=8, d=4. Pretty-print the attention weight matrix.
  3. Add a causal mask and verify each row's leftmost N entries sum to 1.
  4. Multiply every component of Q by 10. Show that the softmax becomes near-one-hot. Now apply scaling by sqrt(d) and discuss.
  5. Implement MultiHeadAttention with d=64, h=8. Sanity-check by feeding random input and printing output shape (B, T, 64).
  6. Visualise an attention head's weights as a heatmap on a real sentence using a small pretrained transformer (e.g., DistilBERT). Look for syntactic patterns.
  7. Bonus: replace softmax with linear attention (Performer-style feature map) and compare on a tiny task.

Common pitfalls

  1. Forgetting the sqrt(d_k) scaling β€” your loss diverges or never moves.
  2. Mask sign confusion β€” pass mask=True for allowed positions in our convention; check carefully when calling library helpers (torch.nn.functional.scaled_dot_product_attention uses additive masks).
  3. Forgetting to apply causal mask in decoder-style attention β†’ leakage from future tokens. Looks like training works but model "memorises" instead of generalising.
  4. Wrong head reshape: (B, T, h*dk).reshape(B, T, h, dk) is fine; .reshape(B, h, T, dk) is not equivalent and silently breaks.
  5. Computing Q @ K^T instead of Q @ K.transpose(-2, -1) β€” same mistake, different bug.

Self-check

  1. Why divide by sqrt(d_k)?
  2. Difference between causal attention and self-attention.
  3. Why do we use multiple heads instead of one big head?
  4. Memory cost of attention as a function of sequence length?
  5. What does GQA share across heads?

References

Sign in to save your progress and earn badges.