Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 130m read

DL S036 · Self-Attention Derived from Scratch

Derive Q, K, V from the 'soft dictionary lookup' story. The single most important primitive in modern deep learning, built up from soft-max weighted averages until you can write scaled dot-product attention on a whiteboard from memory.

🧠SoftwareM07 · Transformers from scratch· Session 036 of 130 130 min

🎯 Derive Q, K, V from the soft dictionary lookup story until you can write scaled dot-product attention on a whiteboard without notes.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 36 / 80 · Module M07 · ~2 hours

The story — a hallway at Google Brain, spring 2017

Picture a whiteboard in Building 40 at Google's Mountain View campus, sometime in early 2017. Ashish Vaswani, Noam Shazeer, and Niki Parmar are standing in front of it. They've been trying to make sequence-to-sequence translation faster. The dominant architecture — stacked LSTMs with additive attention (Bahdanau, Luong) — was state of the art for machine translation, but it was slow: every timestep waited on the previous one, so you couldn't parallelise across the sequence. Training a big EN→DE model took weeks on eight P100 GPUs.

Shazeer, who had been quietly rebellious about recurrence for a while, made a claim on that whiteboard that sounded almost silly at the time: "We don't need the RNN. Attention alone is enough." The paper that came out of that hallway six months later (Vaswani et al., "Attention Is All You Need," NeurIPS 2017) had exactly that title. It trained in 12 hours on 8 P100s and beat every LSTM baseline on WMT14 EN-DE (28.4 BLEU vs the previous 26.3). Not "a bit better." Better AND an order of magnitude cheaper. That combination is what makes a paper detonate.

Eight years later, in 2026, essentially every large model you use every day — GPT-4o, Claude 3.7 Sonnet, Gemini 2.5, Llama 4, DeepSeek-V3, Qwen 2.5, Mistral Large, Grok 3 — is a direct descendant of the equations on that whiteboard. Even AlphaFold's Evoformer, Whisper, Stable Diffusion's text encoder, ESM protein models, and RT-2 for robotics all use the same primitive. The primitive is self-attention. And self-attention is really just three things stacked in a specific way: a dot product, a soft-max, and a weighted average.

For thirty-five sessions we've been walking. Now we start running.

Everything we've built — MLPs, convnets, RNNs, LSTMs, seq2seq with additive attention — was a warm-up. This session is where the modern era of deep learning actually begins. Every model you've heard of in the last five years — GPT-4, Claude, Llama, Gemini, Whisper, Stable Diffusion's text encoder, AlphaFold's Evoformer, ESM, DALL·E, SAM — is built on top of one primitive. That primitive is self-attention. And self-attention is really just three things stacked in a specific way: a dot product, a soft-max, and a weighted average.

If you internalise the derivation in this session, the rest of M07 is downhill. Multi-head, positional encodings, the full block, nanoGPT — all of it is engineering polish on top of the equation you're about to derive. So take your time. Do the numeric worked example on paper. When you can write Attention(Q, K, V) = softmax(Q Kᵀ / √d) V without hesitating and explain what each piece is doing in plain English, you are ready for the next four sessions.

Here's the puzzle we're going to solve. Given a sequence of vectors x₁, x₂, …, x_n (say, word embeddings), we want each position i to produce a new vector y_i that mixes in information from other positions. RNNs solved this by walking left-to-right and squashing everything into a hidden state. That works, but it's sequential (can't parallelise), it forgets long-range detail, and gradients decay through time. Convnets solved it by using local windows — great for images, bad for language where the relevant word might be 40 tokens back. Attention solves it by letting each position reach out to every other position and pull in a weighted mix of their content, in a single parallel matrix multiply.

The trick is: how do we decide the weights? That's the whole game. And the answer — the answer that ate the world — is let the model learn the weights from a similarity score between learned query and key projections.

You will be able to
  • Write scaled dot-product attention `softmax(QKᵀ/√d) V` from memory and explain every symbol.
  • Derive Q, K, V by starting from a soft dictionary lookup and generalising it three steps.
  • Explain why we divide by `√d_k` (and what happens if you forget).
  • Compute a self-attention output by hand for a length-3 sequence with `d=2`.
  • Implement `attention(x, W_q, W_k, W_v)` in ~10 lines of NumPy with the correct output shape.
  • Distinguish self-attention from cross-attention and predict the shape of both.

Prerequisites

  • Session 002 — dot products as similarity, matrix multiplication shapes.
  • Session 003 — you'll be reading (B, T, T) tensors constantly this module.
  • Session 035 — Bahdanau additive attention. This session generalises it.


1 · Start with something you already understand: a dictionary lookup

Forget attention for a moment. Let's talk about Python dictionaries.

d = {"cat": [1.0, 0.0, 0.0], "dog": [0.0, 1.0, 0.0], "fish": [0.0, 0.0, 1.0]} d["cat"] # [1.0, 0.0, 0.0]

This is a hard lookup. You give it a query key, and it returns exactly one value — the one whose key matched exactly. No match → error.

Now imagine your query is fuzzy. You want to ask "give me the value for something roughly like a cat" and get back a mixture — mostly the cat vector, a little bit of dog (also mammal), and almost none of the fish. That's a soft lookup.

We're going to build attention as three generalisations of this soft lookup:

  1. Replace exact-string match with a similarity score (dot product).
  2. Turn the scores into a probability distribution (soft-max).
  3. Learn the keys, values, and queries as projections of the input rather than hardcoding them.

Do those three, and out falls scaled dot-product attention. Let's go.

Attention as a room full of raised hands
🌍 Real world
💻 Code world

2 · Step 1 — from string keys to vector similarity

Instead of keys being strings like "cat", let each key be a vector k_j ∈ ℝ^d. Let each value v_j also be a vector. And let the query q be a vector too.

The natural "how much does q match k_j?" score for two vectors is the dot product:

sj=qkj=i=1dqikj,is_j = q \cdot k_j = \sum_{i=1}^{d} q_i \, k_{j,i}

Two vectors pointing the same direction → large positive score. Perpendicular → zero. Opposite → negative. This is exactly the geometric intuition from S002.

So the soft lookup becomes: compute the similarity of the query to every key, then use those similarities as weights on the values.

import numpy as np
 
# 3 items in our "dictionary", each key/value is 2-D
K = np.array([[1.0, 0.0],   # key for "cat"
              [0.9, 0.1],   # key for "dog"
              [0.0, 1.0]])  # key for "fish"
 
V = np.array([[10.0,  0.0],
              [ 9.0,  1.0],
              [ 0.0, 10.0]])
 
q = np.array([1.0, 0.05])   # a fuzzy "cat-ish" query
 
scores = K @ q              # shape (3,)  — one score per key
print(scores)               # [1.00, 0.905, 0.05]

The score vector [1.00, 0.905, 0.05] says: query is very similar to key 0 (cat), quite similar to key 1 (dog), barely similar to key 2 (fish). Good — that matches our intuition. Now we need to turn those raw scores into weights that sum to 1.


3 · Step 2 — from scores to a probability distribution

We want weights w_j ≥ 0 with Σ_j w_j = 1 so we can take a convex combination of the values. The canonical way to turn arbitrary real numbers into a probability distribution is soft-max:

wj=exp(sj)kexp(sk)w_j = \frac{\exp(s_j)}{\sum_{k} \exp(s_k)}

Two important properties:

  • Monotone. Larger s_j → larger w_j. Order is preserved.
  • Peaky, controllable by scale. Multiply all scores by 10 and soft-max concentrates almost all mass on the max. Divide by 10 and it becomes nearly uniform. Temperature.
def softmax(x):
    e = np.exp(x - x.max())   # subtract max for numerical stability
    return e / e.sum()
 
w = softmax(scores)
print(w)                      # [0.46, 0.42, 0.12]  approximately
 
y = w @ V                     # weighted average of value vectors
print(y)                      # [8.62, 1.62]  approximately

That output y ≈ [8.62, 1.62] is the attention output for query q: mostly the "cat" value [10, 0], with a chunky contribution from "dog" [9, 1], and a tiny bit of "fish" [0, 10]. Exactly what a soft lookup should do.

3.1 · A subtle problem — variance grows with dimension

Here's a quirk that will bite us in step 3. When keys and queries are high-dimensional (say d = 512, typical for real transformers), the dot product q · k = Σ q_i k_i is a sum of d random terms. If q_i, k_i are unit-variance and zero-mean, the dot product has variance d. So typical dot products have magnitude ≈ √d.

Plug big numbers into soft-max and it becomes a one-hot: all mass on the single largest score, zero everywhere else. The gradient through soft-max in that regime is essentially zero — the model can't learn.

Fix: divide the scores by √d_k before soft-max. This is the "scaled" in scaled dot-product attention.

wj=exp(qkj/dk)iexp(qki/dk)w_j = \frac{\exp(q \cdot k_j / \sqrt{d_k})}{\sum_i \exp(q \cdot k_i / \sqrt{d_k})}

We'll re-derive why the √d_k factor is exactly right in §7. For now, believe it and move on.


4 · Step 3 — learn the keys, values, and queries

In our toy example, we hand-wrote K, V, and q. In a real model, we don't have hand-labelled keys and values sitting around — we have a sequence of input embeddings x_1, …, x_n, and we want the model to learn how to turn each x_i into a query, a key, and a value.

The trick: three learned linear projections.

Q=XWQ,K=XWK,V=XWVQ = X W_Q, \quad K = X W_K, \quad V = X W_V

where X ∈ ℝ^{n \times d_{model}} is the input (one row per token), and W_Q, W_K, W_V ∈ ℝ^{d_{model} \times d_k} are learnable weight matrices.

Now the beauty. For each position i, we take q_i (the i-th row of Q) and dot it with every row of K — but we can do this for all queries simultaneously by computing Q Kᵀ, which is an (n × n) matrix of all pairwise scores.

Divide by √d_k, soft-max along each row, multiply by V. Done.

Attention(Q,K,V)=softmax ⁣(QKdk)V\boxed{\text{Attention}(Q, K, V) = \operatorname{softmax}\!\left(\frac{Q K^\top}{\sqrt{d_k}}\right) V}

That's the equation. Six symbols. Read it out loud until it sounds like English.

What each piece is doing
  • Q — 'what am I looking for?' — one query vector per position, derived from that position's input.
  • K — 'what do I offer?' — one key vector per position, derived from that position's input.
  • V — 'here's the content I provide if you attend to me' — one value vector per position.
  • Q Kᵀ — all-pairs similarity matrix, shape (n, n). Entry (i, j) is how much position i wants information from position j.
  • / √d_k — scale so soft-max stays in the sensitive regime.
  • softmax along rows — turn similarities into a probability distribution per query.
  • · V — weighted average of value vectors, using those probabilities as weights.

5 · A numeric worked example — do this on paper

Let's compute self-attention by hand for a length-3 sequence with d_model = d_k = 2. This is small enough to fit on one page and big enough to be honest.

Input X (three tokens, 2-dim embeddings):

X = [[1, 0],
     [0, 1],
     [1, 1]]

Weights (chosen to be simple, not learned):

W_Q = [[1, 0], [0, 1]]     # identity — Q = X
W_K = [[1, 0], [0, 1]]     # identity — K = X
W_V = [[1, 0], [0, 1]]     # identity — V = X

So Q = K = V = X in this toy case.

Step A · scores.

QK=XX=[101011112]Q K^\top = X X^\top = \begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 1 \\ 1 & 1 & 2 \end{bmatrix}

Check: entry (i, j) = x_i · x_j. E.g. (0, 2) = 1·1 + 0·1 = 1. ✓

Step B · scale. d_k = 2, so √d_k = 1.414. Divide every entry:

scaled = [[0.707, 0.000, 0.707],
          [0.000, 0.707, 0.707],
          [0.707, 0.707, 1.414]]

Step C · row-wise soft-max. For row 0: exp([0.707, 0, 0.707]) = [2.028, 1.000, 2.028], sum = 5.056, so weights ≈ [0.401, 0.198, 0.401].

Doing all three rows:

W ≈ [[0.401, 0.198, 0.401],
     [0.198, 0.401, 0.401],
     [0.288, 0.288, 0.424]]

Sanity checks: every row sums to 1 (✓), row 0 attends most to positions 0 and 2 (the ones with a 1 in dim 0 that matches x_0), row 2 attends fairly evenly with a slight bias to itself. Reasonable.

Step D · output. Y = W · V = W · X.

Row 0: 0.401·[1,0] + 0.198·[0,1] + 0.401·[1,1] = [0.802, 0.599].

Row 1: 0.198·[1,0] + 0.401·[0,1] + 0.401·[1,1] = [0.599, 0.802].

Row 2: 0.288·[1,0] + 0.288·[0,1] + 0.424·[1,1] = [0.712, 0.712].

Y ≈ [[0.802, 0.599],
     [0.599, 0.802],
     [0.712, 0.712]]

Compare to the input X = [[1,0], [0,1], [1,1]]. The output at each position has been "smoothed" by mixing in the other positions, weighted by similarity. Position 0 used to be pure "dim-0"; now it has a bit of "dim-1" mixed in (from position 1 and 2). That's attention doing its job — moving information between positions.

Try it

Redo the calculation with W_Q = [[1, 0], [0, 0]] (queries look only at dim 0) and W_K = W_V = I. Predict what happens to the attention weights, then verify. You should see attention collapse toward whichever positions have large dim-0.


6 · Code it up in ten lines of NumPy

import numpy as np
 
def softmax(x, axis=-1):
    x = x - x.max(axis=axis, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=axis, keepdims=True)
 
def self_attention(X, W_Q, W_K, W_V):
    Q = X @ W_Q            # (n, d_k)
    K = X @ W_K            # (n, d_k)
    V = X @ W_V            # (n, d_v)
    d_k = K.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)   # (n, n)
    weights = softmax(scores, axis=-1)
    return weights @ V, weights       # (n, d_v), (n, n)

Run it on our toy input:

X = np.array([[1., 0.], [0., 1.], [1., 1.]])
I = np.eye(2)
Y, W = self_attention(X, I, I, I)
print(Y.round(3))
print(W.round(3))

You should get the numbers from §5. If you don't, the bug is almost always: forgot axis=-1 on soft-max (default axis=None computes soft-max over the whole flattened tensor — a classic gotcha), or forgot to transpose K.

6.1 · Batched version — the shape you'll actually use

Real code processes a batch of B sequences at once, each of length T. Shapes:

X:  (B, T, d_model)
W_Q, W_K, W_V:  (d_model, d_k)  — same weights applied to every batch element and every position
Q, K, V:  (B, T, d_k)
scores = Q @ K.transpose(-1, -2):  (B, T, T)
weights = softmax(scores, axis=-1):  (B, T, T)
output = weights @ V:  (B, T, d_v)

In PyTorch this becomes:

import torch, torch.nn as nn, torch.nn.functional as F, math
 
class SelfAttention(nn.Module):
    def __init__(self, d_model, d_k):
        super().__init__()
        self.W_Q = nn.Linear(d_model, d_k, bias=False)
        self.W_K = nn.Linear(d_model, d_k, bias=False)
        self.W_V = nn.Linear(d_model, d_k, bias=False)
 
    def forward(self, x):
        Q, K, V = self.W_Q(x), self.W_K(x), self.W_V(x)
        scores = Q @ K.transpose(-2, -1) / math.sqrt(K.size(-1))
        weights = F.softmax(scores, dim=-1)
        return weights @ V

Fifteen lines. That is the primitive that ate deep learning.


7 · Why the √d_k? A deeper derivation

Assume the components of q and k are i.i.d. with mean 0 and variance 1. Then:

E[qk]=i=1dkE[qi]E[ki]=0\mathbb{E}[q \cdot k] = \sum_{i=1}^{d_k} \mathbb{E}[q_i] \mathbb{E}[k_i] = 0 Var(qk)=i=1dkVar(qiki)=i=1dk1=dk\operatorname{Var}(q \cdot k) = \sum_{i=1}^{d_k} \operatorname{Var}(q_i k_i) = \sum_{i=1}^{d_k} 1 = d_k

So the standard deviation of a raw dot product is √d_k. If we don't scale, then for d_k = 512 the typical score is ≈ ±23. Feeding scores of that magnitude into soft-max concentrates virtually all mass on the top one or two positions, and the Jacobian of soft-max becomes vanishingly small everywhere else. Gradients die. Training stalls.

Dividing by √d_k restores unit variance on the scores, which keeps soft-max in the "reasonable slope" regime.

War story Forgetting the sqrt during a rewrite

On my very first transformer-from-scratch attempt I copied a Colab notebook that used d_k = 64 (small enough that unscaled soft-max mostly works), then scaled it up to d_k = 512 without adding the / sqrt(d_k) term. Training loss dropped like a rock for 200 steps — because attention weights had collapsed to one-hot vectors and the model was essentially just copying whichever token had the largest dot product with the query. Diverse tokens? Same collapse. It looked like it was training. It wasn't.

The fingerprint: attention entropy near zero (i.e., every distribution is a spike). Always log attention entropy if you're doing anything custom — it's the cheapest sanity check in the world.


8 · Self-attention vs cross-attention

Everything above was self-attention: Q, K, V all come from the same input X. The sequence attends to itself. Every position can look at every other position of the same sequence.

Cross-attention is the same math, but Q comes from one sequence and K, V come from another:

CrossAttention(Xdec,Xenc)=softmax ⁣((XdecWQ)(XencWK)dk)XencWV\text{CrossAttention}(X_{\text{dec}}, X_{\text{enc}}) = \operatorname{softmax}\!\left(\frac{(X_{\text{dec}} W_Q)(X_{\text{enc}} W_K)^\top}{\sqrt{d_k}}\right) X_{\text{enc}} W_V

Shapes:

  • X_dec ∈ (B, T_dec, d)Q ∈ (B, T_dec, d_k)
  • X_enc ∈ (B, T_enc, d)K, V ∈ (B, T_enc, d_k)
  • scores (B, T_dec, T_enc), output (B, T_dec, d_v)

This is how the original transformer decoder pulls information from the encoder in machine translation: the decoder position asks "what's relevant in the source sentence for the word I'm about to generate?" and the encoder positions answer.

Decoder-only transformers (GPT, Llama, most modern LMs) don't have cross-attention — they only use self-attention with a causal mask. We'll build that in S040.


9 · Shape debugging cheatsheet

You will get shape errors. Everyone does. Here's how to diagnose them in 30 seconds.

If self-attention throws a shape error, check in this order
  • 1. Print X.shape. Should be (B, T, d_model). If it's (B, d_model, T) you fed it channels-first, transpose.
  • 2. Print Q.shape, K.shape, V.shape. All should be (B, T, d_k). If d_k is wrong, W_Q/W_K/W_V have wrong output dim.
  • 3. Print scores.shape after Q @ K.transpose(-2, -1). Must be (B, T, T). If it's (B, d_k, d_k), you transposed the wrong axes.
  • 4. Print weights.sum(dim=-1). Every element must equal 1.0 within floating-point error. If not, you soft-maxed on the wrong axis.
  • 5. Print output.shape. Should match (B, T, d_v). If it lost the batch dim, you called .squeeze() somewhere.

10 · The three big pitfalls

War story Pitfall 1 — softmax on the wrong axis

softmax(scores) with no dim= argument computes soft-max over the whole flattened tensor, giving you weights that sum to 1 across the ENTIRE batch and sequence — not per query. Model still runs. Loss is garbage. Always specify dim=-1 for attention.

War story Pitfall 2 — using bias in Q/K/V projections

nn.Linear(d, d_k) includes a bias by default. Attention weights are invariant to a shared bias on all keys (soft-max cancels it), and biases on Q add nothing useful either. Every modern transformer uses bias=False on these three projections. It's not just aesthetic — dropping the biases saves parameters and marginally improves training stability.

War story Pitfall 3 — mixing up n and d

The scores matrix is (n, n) — one entry per pair of positions. The value matrix is (n, d_v) — one row per position, d_v features. These get multiplied (n, n) @ (n, d_v) = (n, d_v). If you find yourself thinking about (d, d) matrices in the attention path, you've confused a projection with attention. Projections mix features. Attention mixes positions.


11 · Mermaid diagram — the attention data flow

Read left to right. Three projections fan out from X, meet in a scores matrix, get soft-maxed into weights, and pull V through to produce the output. Nothing more, nothing less.


12 · Modern-2025 twist — attention is not "solved"

The equation softmax(QKᵀ/√d) V is timeless, but the implementation has been rewritten three times since 2022, each time saving billions of dollars of compute:

  • FlashAttention (Dao et al., 2022, arXiv:2205.14135) — tiled the attention computation to fit in SRAM. Same math, ~2–4× wall-clock speedup on A100/H100.
  • FlashAttention-2 (Dao, 2023) — reordered loops for better GPU occupancy. ~2× on top of FA-1.
  • FlashAttention-3 (Shah, Bikshandi, Zhang et al., 2024) — exploits Hopper H100's asynchronous WGMMA and TMA units + FP8 e4m3 support. Reaches 75% of H100 theoretical peak (740 TFLOPs FP16, 1.2 PFLOPs FP8). This is what makes long-context Llama 3 405B training economically possible.
  • Multi-head Latent Attention (MLA) in DeepSeek-V3 tech report (2024) compresses K and V into a low-rank latent, cutting KV-cache memory by 93.3% vs vanilla MHA. That's why DeepSeek-V3 can serve 128k-token contexts on far fewer H800s than its peers. We revisit MLA in S042/S043.
  • Ring Attention (Liu, Zaharia, Abbeel, 2023) and Blockwise Parallel Transformer shard the sequence across devices, enabling Gemini 1.5's 1M-token context and Llama 4 Scout's 10M-token context (April 2025 release).

The research frontier as of 2026 is linear-attention hybrids (Mamba-2, Jamba 1.5, Griffin, RWKV-7) that mix attention with structured state-space models to get sub-quadratic scaling without giving up in-context recall. Attention isn't dying — it's being composed with recurrence. But every one of those hybrids still uses scaled dot-product attention in at least half its layers. The primitive isn't going anywhere.

Further reading:


13 · Where this fits in the roadmap

We just built the atom. The rest of M07 builds molecules:

  • S037 — Multi-head attention. Run several attention heads in parallel with different projections, concatenate. Lets one head learn syntax, another semantics, another position-relative patterns.
  • S038 — Positional encodings. Attention as-derived is permutation-invariant (shuffle the input tokens and you get shuffled output tokens). Language cares about order. We add position info.
  • S039 — The full transformer block. Attention + FFN + LayerNorm + residual = one transformer block. Stack N of them = a transformer.
  • S040 — nanoGPT. Rebuild karpathy/nanoGPT line by line using everything above.

Common misconception
✗ What most people think

"Q, K, and V are three different kinds of information the model extracts from a token. The query holds what it wants, the key holds what it is, and the value holds its meaning — three genuinely different things pulled out of the embedding."

✓ What is actually true

In self-attention all three are linear projections of the same input vector. Nothing different is extracted; the same vector is read through three different learned lenses. What makes the roles real is not the content but the position in the computation: whatever is projected by W_q gets compared, whatever is projected by W_k gets compared against, and whatever is projected by W_v gets summed. Swap the weight matrices and the roles swap with them. The names describe the wiring, not the semantics.

Why the myth is so sticky

Because the pedagogical story that gets you in the door — a dictionary lookup, where query, key and value really are three separate objects — is genuinely three separate objects in the case it describes. The analogy is load-bearing and correct as far as it goes, and it is doing the hard work of making the mechanism intuitive at all. What it silently drops is that in self-attention the dictionary is built out of the very thing querying it. The model belief only breaks when you try to reason about why W_q and W_k are not redundant, or why cross-attention takes queries from one sequence and keys from another — at which point "they hold different information" gives no purchase, and "they occupy different slots in the same bilinear form" answers immediately.

Prove it to yourself

Show that only the wiring matters, by making the projections identical and watching what breaks:

import numpy as np
x = np.random.randn(4, 8)          # 4 tokens, d = 8
Wq = Wk = np.eye(8)                # tie query and key: identical lens
Wv = np.random.randn(8, 8)

Q, K, V = x @ Wq, x @ Wk, x @ Wv
S = Q @ K.T / np.sqrt(8)
print(np.allclose(S, S.T))         # True -- the score matrix is now SYMMETRIC

# Tied projections force 'i attends to j' == 'j attends to i'.
# Separate Wq, Wk make S asymmetric, which is what lets a pronoun
# attend strongly to its antecedent without the reverse being true.

The asymmetry that separate matrices buy is the whole reason there are two of them, and it has nothing to do with either one holding a different kind of information.

From first principles
Start with the question

Why divide the dot products by sqrt(d_k)? Any constant would rescale the scores — why this one, and why does it depend on the dimension at all?

  1. 1
    A score is q · k = sum over d_k terms of q_i * k_i. Take the components as roughly independent with mean 0 and variance 1, which is what standard initialisation and normalisation give you.
    forced by · this is the actual distribution the network starts in, and the regime that decides whether training gets going at all
  2. 2
    Each product term then has mean 0 and variance 1, and variances of independent terms add. So the score has variance d_k and standard deviation sqrt(d_k).
    forced by · variance is additive over independent summands — this single fact is the entire derivation
  3. 3
    So the spread of the scores grows with the square root of the dimension. At d_k = 64 the typical score magnitude is around 8; at d_k = 128 it is around 11. The dimension silently sets the scale of the input to the softmax.
    forced by · nothing else in the pipeline normalises the score before the exponential
  4. 4
    Softmax on inputs with a large spread saturates: one entry takes nearly all the mass and the rest go to nearly zero. Its Jacobian involves terms like p_i(1 - p_i), which vanishes as any p_i approaches 0 or 1.
    forced by · the exponential turns a difference of a few units into a ratio of hundreds, and a saturated softmax has vanishing derivative
  5. 5
    Therefore, to make the softmax input distribution independent of d_k, divide by the standard deviation — which is exactly sqrt(d_k). The scores then have unit variance whatever the head dimension.
    forced by · dividing a random variable by its standard deviation is the definition of standardising it
⇒ Therefore

Therefore 1/sqrt(d_k) is not a magic constant, it is variance normalisation. It exists so that a wider head does not silently push you into the saturated, gradient-free corner of the softmax.

And note precisely what this predicts, which you can test in five lines: the damage from removing the scaling must grow with head dimension and be negligible for small ones. Generate random q, k, compute the entropy of softmax(q·k) unscaled for d_k of 4, 64, and 512, and watch it collapse toward zero as the dimension rises while the scaled version stays flat. It also explains the design choice everyone notices but rarely connects: multi-head attention splits d_model across heads so that each head's d_k stays modest, which keeps every head in the well-conditioned regime rather than one wide head sitting in the saturated one.

Mental modelEveryone broadcasts, everyone listens

Every token stands in a room. Each one shouts a key — an advertisement of what it is — and simultaneously holds up a query — a description of what it is looking for. Each token compares its own query against every advertisement, turns those match strengths into a set of proportions summing to one, and walks away carrying a blend of everyone's value in those proportions.

That is one layer. The room then repeats with everyone's updated contents, which is why depth matters: after one layer each token knows about its direct matches, after two it knows about its matches' matches. Information travels through the sequence in hops, but every hop is one step in the computation graph, not n.

  • Three projections of the same vector. Query is compared, key is compared against, value is summed. Roles come from position in the formula, not from content.
  • Shapes never lie: (n, d_k) against (n, d_k) transposed gives an (n, n) score matrix, softmaxed along the last axis, then (n, n) x (n, d_v) gives (n, d_v). Every attention bug is a shape or axis bug — check the softmax axis first.
  • Separate W_q and W_k exist to make the score matrix asymmetric. Ties them and you force mutual attention.
  • Cost is n^2 in both time and memory because you materialise a score for every pair. That single quadratic is the origin of every long-context technique that followed.
🔔 Fires when you see

Fire this model the moment you see: an (n, n) tensor in a profile or an out-of-memory trace · a sqrt(d) in a denominator · a softmax whose axis you are unsure about · permutation-invariance being discussed · "why does the model need positional information at all" · set-structured input where you were about to reach for pooling.

The tradeoff

You need a layer that mixes information across positions in a sequence. Self-attention, convolution, or recurrence?

Self-attention
+ you gain constant path length between any two positions, so long-range dependencies are as learnable as short ones; fully parallel across the sequence during training; and the mixing pattern is data-dependent rather than fixed, so the same layer can route differently for different inputs
− you pay time and memory grow with the square of sequence length; it carries almost no inductive bias, so it needs more data than the alternatives to reach the same place; and it is permutation-invariant, so position must be injected separately or it cannot tell an ordering from a set
pick when the dependencies are long-range or content-addressed rather than positional, sequence length is bounded, and you have enough data that a weak prior helps rather than hurts
Convolution
+ you gain cost grows linearly in sequence length; translation equivariance and locality are built in, which is a strong and usually correct prior for signals; extremely cache-friendly and the best-optimised primitive in deep learning
− you pay the receptive field grows only linearly with depth (or exponentially with dilation, at the price of gaps), so genuinely long-range interaction needs many layers; and the mixing pattern is fixed by the kernel, identical for every input
pick when the signal is local and translation-equivariant — audio, images, character-level text — or the sequence is long enough that a quadratic cost is simply unaffordable
Recurrence
+ you gain constant memory per step regardless of how long the stream has run, which is the only option for genuinely unbounded input; a natural fit for causal streaming inference; and a strong sequential prior that helps in small-data regimes
− you pay strictly sequential, so training cannot parallelise across the sequence; and the path between distant positions is linear in their separation, which is exactly the gradient problem gating was invented to mitigate rather than remove
pick when input is an unbounded stream with a fixed per-step memory budget, or data is scarce enough that the sequential prior is worth more than the parallelism you give up
What a senior engineer actually does

Attention won for bounded-context language because constant path length plus full training parallelism is a combination the other two cannot offer, and because at scale the weak inductive bias flipped from a liability into an asset — the model learns the structure rather than having it imposed.

But note that the interesting production architectures are hybrids, and for a reason the tradeoff table makes obvious: convolution or local attention handles the dense short-range mixing cheaply, and a smaller number of global attention layers handles the long-range routing. When you next hit a memory wall, the question to ask is not "how do I make attention cheaper" but "how much of this mixing actually needed to be global" — usually far less than the architecture is paying for.


🧠 Retention scaffold

Quick recall · click to reveal
★ = stretch question

One-line summary (write it in your own words): _______________________________________________

Spaced review: re-read §5 (numeric example) and §7 (√d derivation) in 24 hours. Revisit the full session on day 7. In week 2 revisit §12 (modern twist) as the bridge into S042/S043.

Next session (S037): why one attention head when you can have eight? We split d_model across h heads, run them in parallel, concat and project. Each head learns different relations — syntax, coreference, position-relative offsets. In 2025 the story continues with Grouped-Query Attention (Llama 3), Multi-Query Attention (PaLM), and Multi-head Latent Attention (DeepSeek-V3).

Sticky note (keep on your desk): Attention = weighted sum where weights come from learned similarity. Q asks, K matches, V is pooled. Divide by √d_k to keep softmax honest.


Previous: ← DL S035 · Next: DL S037 →