Week 15: The Attention Mechanism

Week 14 ended with a sketch of attention fixing seq2seq's bottleneck. This week formalizes that sketch into the exact mechanism — scaled dot-product attention, and its multi-head extension — that every transformer, and therefore every modern LLM, is built from. By the end, you'll implement it from scratch and verify it against a known reference.

Phase 5 of 8 Week 15 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain queries, keys and values, and compute scaled dot-product attention by hand
  • Implement multi-head attention and explain why multiple heads help
  • Explain why attention masks are needed and why attention's cost scales quadratically

1. Queries, Keys & Values

Attention reframes Week 14's "relevance score" idea using three learned projections of each token's embedding: a query (what this token is looking for), a key (what this token offers, for others to match against), and a value (the actual content this token contributes once it's deemed relevant).

qkv_projections.py
import numpy as np

d_model = 8    # embedding dimension
seq_len = 4

X = np.random.randn(seq_len, d_model)   # 4 tokens, each an 8-dim embedding

W_q = np.random.randn(d_model, d_model) * 0.1
W_k = np.random.randn(d_model, d_model) * 0.1
W_v = np.random.randn(d_model, d_model) * 0.1

Q = X @ W_q   # queries -- one per token
K = X @ W_k   # keys    -- one per token
V = X @ W_v   # values  -- one per token

A useful analogy: think of it as a search engine. The query is your search text, the keys are every document's indexed title, and the values are the actual document contents. Every token in a sequence simultaneously plays all three roles — it issues a query looking for relevant context, offers a key so other tokens can find it, and carries a value that gets pulled in wherever it's deemed relevant.

2. Scaled Dot-Product Attention, Step by Step

With Q, K, and V in hand, attention is exactly three operations you already know from Weeks 2 and 14: a dot product for relevance, softmax to normalize, and a weighted sum.

scaled_dot_product_attention.py
def softmax(z):
    shifted = z - np.max(z, axis=-1, keepdims=True)
    exp_z = np.exp(shifted)
    return exp_z / np.sum(exp_z, axis=-1, keepdims=True)

def attention(Q, K, V):
    d_k = K.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)   # (seq_len, seq_len) -- every query vs. every key
    weights = softmax(scores)         # each row sums to 1 -- a distribution over "which tokens matter"
    return weights @ V                # weighted sum of values, per query

output = attention(Q, K, V)   # shape: (seq_len, d_model) -- one context-aware vector per token

The division by sqrt(d_k) is the "scaled" part — without it, dot products grow larger as the embedding dimension grows, pushing softmax's inputs into a range where its gradient becomes tiny (the same saturation problem sigmoid has, from Week 11). Scaling by the square root of the key dimension keeps the scores in a well-behaved range regardless of d_model. The output for each token is a weighted blend of every value in the sequence, weighted by how relevant that token's query found each key — exactly the "look back at everything, weighted by relevance" idea from Week 14, made precise.

3. Multi-Head Attention & Why Multiple Heads Help

A single attention computation can only capture one notion of "relevance" at a time. Multi-head attention runs several smaller attention computations in parallel — each with its own learned Q/K/V projections — and concatenates their outputs, letting different heads specialize in different kinds of relationships (one head might track grammatical subject-verb agreement, another might track coreference between a pronoun and the noun it refers to).

multi_head_attention.py
def multi_head_attention(X, num_heads, d_model):
    d_k = d_model // num_heads
    outputs = []

    for _ in range(num_heads):
        W_q = np.random.randn(d_model, d_k) * 0.1
        W_k = np.random.randn(d_model, d_k) * 0.1
        W_v = np.random.randn(d_model, d_k) * 0.1
        Q, K, V = X @ W_q, X @ W_k, X @ W_v
        outputs.append(attention(Q, K, V))   # each head's own (seq_len, d_k) output

    concatenated = np.concatenate(outputs, axis=-1)   # back to (seq_len, d_model)
    W_o = np.random.randn(d_model, d_model) * 0.1
    return concatenated @ W_o   # final learned mixing of all heads' information

Splitting d_model across num_heads smaller heads (rather than running num_heads full-size attentions) keeps the total computation comparable to a single large attention operation, while still giving the model multiple independent "perspectives" on the same sequence — a genuinely richer representation for roughly the same cost.

4. Self-Attention vs. Cross-Attention, and Attention Masks

When queries, keys and values all come from the same sequence, it's called self-attention — each token attending to every other token in the same input. When queries come from one sequence but keys/values come from a different one (e.g. a decoder attending back to an encoder's output), it's cross-attention — this is precisely the mechanism from Week 14's seq2seq fix, and you'll see it again in Week 16's encoder-decoder architecture.

causal_mask.py
# A causal mask prevents a token from attending to future tokens --
# essential for a model that generates text left-to-right, one token at a time
seq_len = 4
mask = np.triu(np.ones((seq_len, seq_len)), k=1).astype(bool)   # True above the diagonal

def masked_attention(Q, K, V, mask):
    d_k = K.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)
    scores = np.where(mask, -np.inf, scores)   # forbidden positions get -infinity
    weights = softmax(scores)                   # softmax(-inf) -> 0, effectively removing them
    return weights @ V

Setting forbidden scores to -infinity before softmax is the key trick: softmax turns -inf into a probability of exactly 0, so those positions contribute nothing to the weighted sum — a clean way to enforce "don't look ahead" without changing the attention mechanism's core math. This causal mask is exactly what makes the decoder-only architecture you'll build in Week 16 generate text one token at a time without "cheating" by seeing the answer.

5. Computational Cost of Attention

Computing Q @ K.T produces a (seq_len, seq_len) matrix of scores — meaning attention's compute and memory cost grows quadratically with sequence length. Doubling the input length quadruples the attention computation, which is precisely why "context window" (Week 17) is both an architectural and a cost constraint, and why serving very long contexts (Week 25) is expensive.

Why this matters beyond theory

This quadratic cost is a direct, practical tradeoff you'll navigate in Week 25 when reasoning about latency and cost for a deployed LLM feature — a request with 10x the context isn't 10x the cost, it can be closer to 100x for the attention computation alone.

6. Hands-on Exercise

Hands-on

Implement multi-head attention and verify it against PyTorch's reference

Build attention from scratch in raw NumPy, then check your implementation against a trusted reference.

Requirements:

  1. Implement scaled_dot_product_attention(Q, K, V) from Section 2, including the sqrt(d_k) scaling.
  2. Implement multi_head_attention from Section 3, for a configurable number of heads.
  3. Add a causal mask (Section 4), and confirm a token at position i assigns exactly zero attention weight to any position j > i.
  4. Using fixed, identical weight matrices, compute your NumPy implementation's output on a small example, then compute the same thing using torch.nn.MultiheadAttention (or manually reimplement the same math in PyTorch), and confirm the outputs match within floating-point tolerance.
  5. Increase the sequence length from 4 to 4,000 and time the attention computation at both lengths — does the runtime ratio look closer to 1,000x (linear) or 1,000,000x (quadratic)? Explain your result using Section 5.
Hint

For step 4, the easiest way to guarantee identical weights across both implementations is to explicitly copy the same NumPy arrays into PyTorch tensors, rather than relying on both frameworks' random initializations to somehow match.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

In the query/key/value framing, what role does each of the three play?

The query represents what the current token is "looking for"; the key represents what each token "offers" to be matched against a query; the value is the actual content pulled in once a token is deemed relevant via the query-key match. Every token participates as a query, a key, and a value simultaneously.

Q2

Why divide the attention scores by sqrt(d_k) before applying softmax?

Without scaling, dot products grow larger as the key dimension increases, pushing softmax's inputs into a range where its gradient becomes very small — similar to sigmoid saturation from Week 11. Dividing by sqrt(d_k) keeps the scores in a well-behaved range regardless of the embedding dimension, preserving useful gradients during training.

Q3

Why run several smaller attention heads in parallel instead of one large attention computation?

Each head learns its own Q/K/V projections and can specialize in a different kind of relationship between tokens (e.g. grammatical structure vs. coreference), giving the model multiple independent "perspectives" on the same sequence. Splitting the same total dimension across heads keeps the overall computation comparable to one large attention call, while gaining this representational diversity essentially for free.

Q4

Why does setting masked positions to -infinity before softmax correctly implement a causal mask?

Softmax computes exp(score) / sum(exp(all scores)), and exp(-infinity) evaluates to exactly 0 — so masked positions receive exactly zero attention weight and contribute nothing to the resulting weighted sum, without needing any special-case logic elsewhere in the computation.