Self-attention from scratch
The Q·K·V math that powers every modern LLM — derived step by step in NumPy with shape annotations at every line.
What you'll learn
- The Query/Key/Value mental model for attention
- The exact shapes at each step of self-attention
- How causal masking enables autoregressive generation
Before you start
Self-attention is the single mechanism that defines every modern LLM. It’s also surprisingly simple math — a few matrix multiplications and a softmax. The hard part is keeping the shapes straight. Let’s build it from scratch.
Watch every token decide where to look
Each row is one query token asking “how much should I attend to each other token?” — a softmax distribution that sums to 1. Pick a row to see its links.
The mental model
For each token in the sequence, attention computes a weighted average of all other tokens’ values, where the weights come from how well that token’s “query” matches the other tokens’ “keys.”
For token i:
q_i = "what am I looking for?"
k_j = "what do I have to offer?" (for every j)
v_j = "what do I contribute?" (for every j)
weights = softmax(q_i · k_j / √d_k) over all j
output_i = Σ weights_j · v_j
Q, K, V are all derived from the same input tokens — that’s why it’s “self” attention. The token both asks and answers.
That softmax over all j is the whole game: every token spreads a fixed
budget of attention across the sequence. Each row below is one token’s
distribution — and the “it” row reaches back to its referent, “cat”:
Build it, end to end
We’ll attend over a sequence of 4 tokens, each represented by a 6-dim vector.
import numpy as np
rng = np.random.default_rng(0)
# A sequence of 4 tokens, each a 6-dim embedding
# Shape: (seq_len, d_model) = (4, 6)
x = rng.standard_normal((4, 6))
# Three projection matrices that turn embeddings into Q, K, V
# In a real model these are nn.Linear layers — here we use random matrices
d_model = 6
d_k = 8 # head dimension (in real models, d_model // n_heads)
W_q = rng.standard_normal((d_model, d_k))
W_k = rng.standard_normal((d_model, d_k))
W_v = rng.standard_normal((d_model, d_k))
# Step 1: project x to Q, K, V
Q = x @ W_q # (4, 8)
K = x @ W_k # (4, 8)
V = x @ W_v # (4, 8)
print(f"Q shape: {Q.shape}, K shape: {K.shape}, V shape: {V.shape}")
Q shape: (4, 8), K shape: (4, 8), V shape: (4, 8)
So far we’ve just projected each token three times. Now the attention math:
import numpy as np
rng = np.random.default_rng(0)
x = rng.standard_normal((4, 6))
d_k = 8
Q = x @ rng.standard_normal((6, d_k))
K = x @ rng.standard_normal((6, d_k))
V = x @ rng.standard_normal((6, d_k))
# Step 2: scores = Q · K^T shape: (4, 4)
# scores[i, j] = how much token i "queries" token j
scores = Q @ K.T
print("scores shape:", scores.shape)
# Step 3: scale by sqrt(d_k) to keep variance under control
scores = scores / np.sqrt(d_k)
# Step 4: softmax over the LAST axis (j) so each row sums to 1
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
weights = softmax(scores, axis=-1)
print("weights row sums:", weights.sum(axis=-1))
print("weights[0]:", weights[0].round(3)) # how token 0 attends to each token
# Step 5: weighted sum of values
output = weights @ V # (4, 4) @ (4, 8) = (4, 8)
print("output shape:", output.shape)
scores shape: (4, 4)
weights row sums: [1. 1. 1. 1.]
weights[0]: [0.137 0.859 0.001 0.003]
output shape: (4, 8)
That’s it. The “transformer revolution” is five lines of NumPy:
scores = Q @ K.T / sqrt(d_k) # (n, n) — pairwise similarity
weights = softmax(scores, axis=-1) # (n, n) — each row is a distribution
output = weights @ V # (n, d) — weighted blend of values
Why scale by √d_k?
Q · K^T is a sum of d_k products — its variance grows with d_k,
pushing values into softmax’s flat regions where gradients vanish.
Dividing by √d_k keeps variance constant regardless of head dimension.
Without it, large models don’t train.
Causal masking — making generation work
For autoregressive (GPT-style) models, token i must not attend to
future tokens — otherwise training would just cheat by looking at the
next token directly.
Fix: mask the upper triangle of scores with -inf before softmax.
-inf becomes 0 after softmax — zero attention weight.
import numpy as np
seq_len = 4
# A causal mask: 1 where attention is allowed, 0 where forbidden
mask = np.tril(np.ones((seq_len, seq_len)))
print("causal mask:")
print(mask)
# Apply: set forbidden positions to -inf BEFORE softmax
scores = np.array([
[1.0, 2.0, 0.5, 1.5],
[0.3, 2.1, 0.8, 0.9],
[1.2, 0.5, 1.8, 0.4],
[0.7, 1.3, 1.0, 2.5],
])
masked = np.where(mask, scores, -np.inf)
print("\nmasked scores:")
print(masked)
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
print("\nweights after softmax (notice upper triangle = 0):")
print(softmax(masked, axis=-1).round(3))
causal mask:
[[1. 0. 0. 0.]
[1. 1. 0. 0.]
[1. 1. 1. 0.]
[1. 1. 1. 1.]]
masked scores:
[[ 1. -inf -inf -inf]
[ 0.3 2.1 -inf -inf]
[ 1.2 0.5 1.8 -inf]
[ 0.7 1.3 1. 2.5]]
weights after softmax (notice upper triangle = 0):
[[1. 0. 0. 0. ]
[0.142 0.858 0. 0. ]
[0.301 0.15 0.549 0. ]
[0.098 0.178 0.132 0.592]]
Notice: the first row only attends to position 0. The second row attends to positions 0–1. By the last row, the token can see everything. This is how GPT learns to predict the next token from the previous ones.
Cost reality
Q @ K.T has shape (n, n) — quadratic in sequence length. Doubling
the context window quadruples this matrix. That’s why “million-token
context” needed years of engineering: FlashAttention, sparse patterns,
KV-cache tricks. The math we just wrote is the bottleneck.
In real PyTorch
import torch.nn.functional as F
# Fused, FlashAttention-backed when available — 2–4x faster, less memory.
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
F.scaled_dot_product_attention automatically dispatches to
FlashAttention or a memory-efficient kernel when available. Use it for
any real training run — don’t roll your own.
In one breath
- Self-attention gives each token a weighted average of every token’s value — weights come from how well that token’s query matches the others’ keys (Q, K, V all from the same input).
- The whole thing is three steps: scores = Q·Kᵀ/√d_k, weights = softmax(scores), output = weights·V.
- The √d_k scaling keeps score variance constant across head sizes so softmax doesn’t saturate and kill the gradient.
- Causal masking sets future positions to −∞ before softmax (weight 0), so token i sees only tokens 0..i — what makes autoregressive generation honest.
- Scores are n×n — quadratic in sequence length — which is why long context took FlashAttention, sparse patterns, and KV-cache tricks; in real code use F.scaled_dot_product_attention.
Quick check
Quick check
Questions about this lesson
What is self-attention in a transformer?
Self-attention lets each token look at every other token, weigh how relevant each is, and build its new representation as a weighted blend of them. That's how a transformer captures context and long-range relationships in parallel.
What are queries, keys, and values in attention?
Each token produces a query (what it's looking for), a key (what it offers), and a value (its content). Attention scores come from comparing queries to keys; those scores then weight the values that are summed into the output.
Why divide by the square root of the dimension in attention?
Dot products of queries and keys grow with the vector dimension, which can push the softmax into tiny gradients. Dividing by the square root of the key dimension keeps the scores at a stable scale so training stays well-conditioned.
Practice this in an interview
All questionsSelf-attention computes a dot product between every query and every key, producing an n-by-n score matrix that must be stored and normalised. Both compute and memory scale quadratically with sequence length, which becomes prohibitive at context lengths beyond a few thousand tokens. Approximate attention methods trade exact attention for sub-quadratic complexity.
Self-attention lets each token build a representation by attending to every other token: it scores its Query against all Keys, normalizes the scores with softmax, and takes a weighted sum of the Values. Q, K, and V are learned linear projections of the input that respectively represent what a token is looking for, what it offers as a match key, and the content it contributes.
An LSTM maintains a cell state that flows through time via additive updates controlled by learned gates, giving gradients a near-linear path across many steps. The forget, input, and output gates let the network selectively retain, write, and expose information rather than crushing every signal through a squashing non-linearity at every step.
The query represents what a token is looking for, the key represents what a token is advertising about itself, and the value is the content it contributes if selected. Attention scores measure query-key compatibility, and the output is a soft retrieval: a weighted sum of values where the weights come from those compatibility scores.
Transformers win on three axes: parallelism (no sequential dependency lets all positions train simultaneously on GPUs), path length (any two tokens interact in O(1) layers, not O(n) steps), and scalability (attention over longer contexts keeps improving with more compute, while RNN quality degrades with sequence length despite training costs).
For large key dimensions, the dot products between query and key vectors grow in magnitude proportionally to d_k, pushing the softmax into regions with very small gradients. Dividing by sqrt(d_k) keeps the pre-softmax scores at unit variance regardless of dimension, stabilising training.
For large key dimension d_k, the dot products grow large in magnitude, pushing softmax into saturated regions where gradients are tiny. Dividing by the square root of d_k keeps the score variance around one, stabilizing gradients and training.
Self-attention lets every position in a sequence directly query every other position, producing a weighted blend of value vectors where the weights reflect learned pairwise relevance. This gives the model a constant-depth path between any two tokens regardless of how far apart they are, which is what enables transformers to capture long-range dependencies that RNNs miss.
Vanilla RNNs suffer from vanishing (and exploding) gradients when backpropagating through many time steps, which prevents them from learning dependencies that span more than a handful of tokens. They are also inherently sequential — each step depends on the previous hidden state — so they cannot be parallelised during training.
The context window is the maximum number of tokens an LLM can attend to in a single forward pass — both the input prompt and the model's own generated output count toward this limit. Its size determines how much prior text influences each prediction, sets a hard ceiling on document length and conversation history, and drives memory and compute costs that scale quadratically with sequence length under standard attention.
The KV cache stores the key and value tensors computed during previous forward passes so they do not need to be recomputed for every new token during autoregressive generation. Without it, generating each token would require a full forward pass over the entire context from scratch, making inference cost grow quadratically with sequence length rather than linearly.
Padding adds dummy tokens to shorter sequences so all examples in a batch share the same length, which is required for tensor operations. Attention masks tell the model to ignore padded positions, preventing them from contributing to loss or attention scores.