AI Knowledge Tree

Self-attention

Define scaled dot-product self-attention and explain why the softmax of QK^T gives a content-based mixing matrix.

⏱ 30 minPrerequisites:Seq2seq with attention

The single operation behind the Transformer revolution: self-attention[1]. Each token in a sequence computes a query, a key, and a value; the attention from token ii to token jj is the (scaled, softmaxed) inner product of ii's query with jj's key, used to weight jj's value into ii's output. Three projections, one softmax: content-based mixing across arbitrary positions in one parallel operation.

The operation

Given a sequence of NN token embeddings stacked as X∈RN×dX \in \mathbb{R}^{N \times d}:

  1. Project each token to a query, key, and value with learned matrices WQ,WK,WV∈Rd×dkW_Q, W_K, W_V \in \mathbb{R}^{d \times d_k}: Q=XWQ,K=XWK,V=XWV.Q = X W_Q, \quad K = X W_K, \quad V = X W_V.
  2. Compute the attention scores as scaled dot products: S=QK⊤dk.S = \frac{Q K^\top}{\sqrt{d_k}}.
  3. Apply softmax row-wise to get attention weights: A=softmax⁡row(S).A = \operatorname{softmax}_{\text{row}}(S).
  4. The output is the weighted sum of values: Y=AV.Y = A V.

The output Y∈RN×dkY \in \mathbb{R}^{N \times d_k} has one row per token, each a content-based mixture of the values of every other token.

What each piece does

  • Query: "what am I looking for?" Each token's query interrogates the rest of the sequence.
  • Key: "what do I offer?" Each token's key says what it can answer.
  • Value: "what do I contribute?" Each token's value is what gets mixed in if it is attended to.

The softmax row Ai,:A_{i, :} is a probability distribution over the sequence, specifying how much token ii attends to each other token.

The dk\sqrt{d_k} scaling

Without scaling, the dot product q⊤kq^\top k has variance dkd_k when q,kq, k are unit-variance Gaussians. Large dkd_k pushes the softmax into the saturated regime where one entry dominates and gradients vanish. Dividing by dk\sqrt{d_k} keeps the variance constant in dkd_k, preserving useful attention distributions.

Concretely, take dk=64d_k = 64 (a standard head size) and one query against 1010 keys, all unit-variance Gaussian. The raw scores then have standard deviation 64=8\sqrt{64} = 8, so the largest is typically several units above the rest, and the softmax collapses onto it: averaged over random draws, the top weight sits at about 0.860.86 of the total mass, nearly a hard argmax over 1010 positions. Divide by dk=8\sqrt{d_k} = 8 and the scores have unit spread; the top weight drops to about 0.320.32, a distribution that still spreads attention and still has gradient. The scaling is the difference between a soft lookup and a saturated one-hot.

Why it works

Self-attention has three properties that make it dominant for sequences:

  • Content-based addressing: tokens find their relevant neighbors by similarity, not position. A token can attend to anything anywhere.
  • Parallel over positions: all NN outputs are computed simultaneously, unlike RNNs which serialize through time. GPU-friendly.
  • Constant path length: any pair of tokens is exactly one operation away from influencing each other, no matter how far apart they are in the sequence. RNNs have O(N)O(N) path length; self-attention has O(1)O(1).

The cost: quadratic in sequence length. Computing QK⊤QK^\top is O(N2dk)O(N^2 d_k) time and O(N2)O(N^2) memory. For long sequences (1k, 100k, 1M tokens) this becomes the bottleneck, motivating the linear-attention and SSM lessons later.

Multi-head attention

A single attention computation uses one fixed dkd_k. Multi-head attention runs HH parallel attentions on different dkd_k-dimensional projections of the input, then concatenates their outputs:

MultiHead(X)=[head1;head2;… ;headH]WO,\text{MultiHead}(X) = [\text{head}_1; \text{head}_2; \dots; \text{head}_H] W_O,

where each headh=Attention(XWQh,XWKh,XWVh)\text{head}_h = \text{Attention}(X W_Q^h, X W_K^h, X W_V^h) and WO∈RHdk×dW_O \in \mathbb{R}^{H d_k \times d} is a learned output projection.

With H=8H = 8 heads and dk=d/H=64d_k = d/H = 64 (for d=512d = 512), each head can specialize: one head might attend to syntactic neighbors, another to semantic similarity, another to positional patterns. Empirically heads specialize without being told to.

Masking

For autoregressive language modeling, the decoder must not see future tokens. Apply a causal mask to the attention scores:

Si,j={Si,jj≤i−∞j>i.S_{i, j} = \begin{cases} S_{i, j} & j \le i \\ -\infty & j > i \end{cases}.

After softmax, Ai,j=0A_{i, j} = 0 for j>ij > i. Token ii only attends to tokens at positions ≤i\le i.

For variable-length sequences in a batch, a padding mask is also applied so padded positions get −∞-\infty scores.

Self-attention vs cross-attention

  • Self-attention: QQ, KK, VV all come from the same sequence. Used in encoder layers and self-attention sublayers of decoder layers.
  • Cross-attention: QQ comes from the decoder, KK and VV come from the encoder. The decoder queries the encoder's representation. Used in encoder-decoder Transformers (translation, summarization). This is the direct successor of seq2seq attention[2].

The next lesson assembles these primitives into the full Transformer.

Self-attention as a soft lookup. Left: the full attention matrix, where every token attends to every other. Right: a causal mask zeros out the future (lower-triangular), as needed for autoregressive generation.
Self-attention as a soft lookup. Left: the full attention matrix, where every token attends to every other. Right: a causal mask zeros out the future (lower-triangular), as needed for autoregressive generation.

Check it on arrays

python · numpy
import numpy as np

rng = np.random.default_rng(0)

def softmax(z, axis):
    z = z - z.max(axis=axis, keepdims=True)
    e = np.exp(z); return e / e.sum(axis=axis, keepdims=True)

def attention(Q, K, V, mask=None):
    d_k = Q.shape[-1]
    S = Q @ K.T / np.sqrt(d_k)
    if mask is not None:
        S = np.where(mask, S, -np.inf)
    A = softmax(S, axis=-1)
    return A @ V, A

# 5-token sequence, model dim 8
N, d, d_k = 5, 8, 8
X = rng.normal(size=(N, d))
W_q = rng.normal(scale=0.3, size=(d, d_k))
W_k = rng.normal(scale=0.3, size=(d, d_k))
W_v = rng.normal(scale=0.3, size=(d, d_k))

Q = X @ W_q; K = X @ W_k; V = X @ W_v
Y, A = attention(Q, K, V)
print(f"attention matrix shape: {A.shape}, output shape: {Y.shape}")
print(f"each row of A sums to 1: {[round(a, 3) for a in A.sum(axis=1)]}")

# Causal mask: lower-triangular ones
causal = np.tril(np.ones((N, N))).astype(bool)
Y_c, A_c = attention(Q, K, V, mask=causal)
print(f"\ncausal attention matrix (row i can only see j <= i):")
print(A_c.round(2))
print(f"row 2 mass on positions > 2: {A_c[2, 3:].sum():.6f}  (should be 0)")

# Multi-head: 4 heads, each with d_k = d / 4 = 2
H = 4; d_k = d // H
W_qm = rng.normal(scale=0.3, size=(H, d, d_k))
W_km = rng.normal(scale=0.3, size=(H, d, d_k))
W_vm = rng.normal(scale=0.3, size=(H, d, d_k))
W_o = rng.normal(scale=0.3, size=(H * d_k, d))

heads = []
for h in range(H):
    Qh = X @ W_qm[h]; Kh = X @ W_km[h]; Vh = X @ W_vm[h]
    Yh, _ = attention(Qh, Kh, Vh)
    heads.append(Yh)
Y_multi = np.concatenate(heads, axis=-1) @ W_o
print(f"\nmulti-head output shape: {Y_multi.shape}")

The attention matrix is row-stochastic, the causal mask zeroes out future positions, and the multi-head computation returns to the original embedding dimension.

Exercises

Exercise 1. A single query q=(1,0)q = (1, 0) attends to two keys k1=(1,0)k_1 = (1, 0) and k2=(0,1)k_2 = (0, 1), with dk=2d_k = 2 and values v1=(4,0)v_1 = (4, 0), v2=(0,4)v_2 = (0, 4). Compute the scaled scores, the softmax attention weights, and the output by hand. Use e1/2≈2.0281e^{1/\sqrt{2}} \approx 2.0281 and 2≈1.4142\sqrt{2} \approx 1.4142.

Solution

Step 1. The scaled scores are q⊤kj/dkq^\top k_j / \sqrt{d_k}. Then q⊤k1=1q^\top k_1 = 1 and q⊤k2=0q^\top k_2 = 0, so dividing by 2\sqrt{2}: s1=1/2≈0.7071s_1 = 1/\sqrt{2} \approx 0.7071 and s2=0s_2 = 0.

Step 2. Softmax: es1≈2.0281e^{s_1} \approx 2.0281, es2=1e^{s_2} = 1, summing to 3.02813.0281. The weights are a1=2.0281/3.0281≈0.6698a_1 = 2.0281 / 3.0281 \approx 0.6698 and a2=1/3.0281≈0.3302a_2 = 1 / 3.0281 \approx 0.3302.

Step 3. The output is the weighted sum of values:

y=a1v1+a2v2≈0.6698⋅(4,0)+0.3302⋅(0,4)=(2.679, 1.321).y = a_1 v_1 + a_2 v_2 \approx 0.6698 \cdot (4, 0) + 0.3302 \cdot (0, 4) = (2.679,\ 1.321).

The query aligns with k1k_1, so most of the mass (0.670.67) lands on v1v_1; the scaling by dk\sqrt{d_k} keeps the distribution soft instead of collapsing onto a single key.

Exercise 2. Without scaling, q⊤kq^\top k for unit-variance Gaussian q,k∈Rdkq, k \in \mathbb{R}^{d_k} has variance dkd_k. Show this, and state what dividing by dk\sqrt{d_k} does to the variance.

Solution

Step 1. Write the dot product as a sum: q⊤k=∑m=1dkqmkmq^\top k = \sum_{m=1}^{d_k} q_m k_m. Each qm,kmq_m, k_m is independent, zero-mean, unit-variance.

Step 2. For one term, E[qmkm]=E[qm]E[km]=0\mathbb{E}[q_m k_m] = \mathbb{E}[q_m]\mathbb{E}[k_m] = 0 and Var⁡(qmkm)=E[qm2]E[km2]=1⋅1=1\operatorname{Var}(q_m k_m) = \mathbb{E}[q_m^2]\mathbb{E}[k_m^2] = 1 \cdot 1 = 1 (independence, zero means). The terms are independent, so variances add: Var⁡(q⊤k)=∑m=1dk1=dk\operatorname{Var}(q^\top k) = \sum_{m=1}^{d_k} 1 = d_k.

Step 3. Dividing by dk\sqrt{d_k} scales the variance by (1/dk)2=1/dk(1/\sqrt{d_k})^2 = 1/d_k, giving Var⁡(q⊤k/dk)=dk/dk=1\operatorname{Var}(q^\top k / \sqrt{d_k}) = d_k / d_k = 1.

The scaling fixes the score variance at 11 regardless of head size, so the softmax does not saturate as dkd_k grows.

Exercise 3 (code). Confirm the variance argument of Exercise 2 empirically, and show that the unscaled softmax saturates while the scaled one stays soft. Sample many random (q,k)(q, k) pairs at dk=64d_k = 64, measure the variance of q⊤kq^\top k with and without scaling, and compare the average top softmax weight over 1010 keys.

Solution
python · numpy
import numpy as np

rng = np.random.default_rng(0)
d_k, n_keys, trials = 64, 10, 20000

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

# Variance of q . k with and without the 1/sqrt(d_k) scaling
q = rng.normal(size=(trials, d_k))
k = rng.normal(size=(trials, d_k))
dots = np.sum(q * k, axis=1)
var_unscaled = dots.var()
var_scaled = (dots / np.sqrt(d_k)).var()
assert np.isclose(var_unscaled, d_k, rtol=0.05)   # variance ~ d_k
assert np.isclose(var_scaled, 1.0, rtol=0.05)     # scaling -> variance ~ 1

# Top softmax weight over n_keys keys, unscaled vs scaled
q1 = rng.normal(size=(trials, d_k))
K = rng.normal(size=(trials, n_keys, d_k))
scores = np.einsum("td,tkd->tk", q1, K)
top_unscaled = softmax(scores).max(axis=1).mean()
top_scaled = softmax(scores / np.sqrt(d_k)).max(axis=1).mean()
assert top_unscaled > top_scaled                  # unscaled saturates harder
print("var unscaled:", round(var_unscaled, 1), " var scaled:", round(var_scaled, 3))
print("mean top weight  unscaled:", round(top_unscaled, 3),
      " scaled:", round(top_scaled, 3))

The unscaled dot product has variance near dk=64d_k = 64 and its softmax concentrates most mass on one key; dividing by dk\sqrt{d_k} restores unit variance and a soft, gradient-carrying distribution.

The next lesson stacks attention and FFN blocks into the full Transformer.


References

  1. [1]Vaswani, A., et al. (2017). "Attention is all you need". NeurIPS. arXiv:1706.03762. Defines scaled dot-product and multi-head self-attention.↩
  2. [2]Bahdanau, D., Cho, K., & Bengio, Y. (2015). "Neural machine translation by jointly learning to align and translate". ICLR. arXiv:1409.0473. The encoder-decoder attention that cross-attention generalizes.↩