§3.2Self-Attention: Scaled Dot-Product Attention

Part I NLPT pp. 4–11 · ~10 min read

  • self-attention
  • attention weights
  • contextualized embedding
  • scaled dot-product attention
  • query
  • key
  • value
  • attention scores

Self-attention is the beating heart of the transformer. This section builds it from first principles: the idea (a context-dependent weighted average), the concrete recipe (scaled dot-product attention), the PyTorch code, and an interactive lab you can poke at.

The idea: a context-dependent weighted average

Self-attention lets each token’s representation depend on the whole sequence instead of being fixed. Given token embeddings x1,,xnx_1, \dots, x_n, it produces new embeddings x1,,xnx_1', \dots, x_n' where each xix_i' is a weighted combination of all the inputs:

xi=j=1nwjixj,j=1nwji=1x_i' = \sum_{j=1}^{n} w_{ji}\, x_j, \qquad \sum_{j=1}^{n} w_{ji} = 1

Reading it term by term:

  • xix_i' — the updated embedding for token ii (same dimension as xix_i).
  • xjx_j — the original embedding of token jj (one term per token in the sequence).
  • wjiw_{ji} — the attention weight : how much token jj contributes to token ii‘s new representation.
  • jwji=1\sum_j w_{ji} = 1 — the weights over jj form a proper average (they’re non-negative and sum to one).

Intuition — “a fancy form of averaging”

A word’s meaning is coloured by its neighbours. When you read “time flies like an arrow”, the word flies is a verb; in “fruit flies like a banana” it’s a noun. Self-attention builds a representation of “flies” by mixing in the other tokens — heavily weighting “time” and “arrow” in the first sentence, “fruit” and “banana” in the second. The result is a contextualized embedding : same word, different vector, because different context.

Worked example. Suppose “flies” attends with weights w=[0.01,0.18,0.01,0.01,0.80]w = [0.01, 0.18, 0.01, 0.01, 0.80] over [time, flies, like, an, arrow]. Then its updated embedding is

xflies=0.01xtime+0.18xflies+0.01xlike+0.01xan+0.80xarrowx'_{\text{flies}} = 0.01\,x_{\text{time}} + 0.18\,x_{\text{flies}} + 0.01\,x_{\text{like}} + 0.01\,x_{\text{an}} + 0.80\,x_{\text{arrow}}

— mostly “arrow” with a dash of its own identity. Those weights aren’t invented; the rest of this section is about how to compute them.

“time flies like an arrow”“fruit flies like a banana”time · flies · like · an · arrowfruit · flies · like · a · bananaself-attention (context of “flies”)self-attention (context of “flies”)flies ≈ “soars”flies ≈ “insect”verb — informed by “time”, “arrow”noun — informed by “fruit”, “banana”
Figure 3-3 (recreated). Self-attention turns the identical raw embedding for “flies” into two different contextualized embeddings, one per sentence.

Scaled dot-product attention

There are several ways to compute those weights; the standard one — from Attention Is All You Need — is scaled dot-product attention , in four steps:

  1. Project each embedding into three vectors: a query , a key , and a value .
  2. Score every query against every key with a dot product (the similarity function). For nn tokens this yields an n×nn \times n matrix of attention scores .
  3. Scale and softmax the scores into weights that sum to 1.
  4. Weighted-sum the value vectors using those weights.
QKVMatMulScaleMask(optional)SoftmaxMatMuloutput
Figure 3-4 (recreated). The operations of scaled dot-product attention. (The mask step is used only in the decoder — see §3.5.)

Step 2 — score with the dot product

The similarity between query qiq_i and key kjk_j is their dot product. Stacking all queries into a matrix QQ and all keys into KK, every pairwise score is a single matrix multiply:

S=QK,Sij=qikj=dqi,dkj,dS = Q K^{\top}, \qquad S_{ij} = q_i \cdot k_j = \sum_{d} q_{i,d}\, k_{j,d}
  • QQ — the queries, shape [n,dk][n, d_k] (one row per token).
  • KK — the keys, shape [n,dk][n, d_k]; KK^{\top} is its transpose [dk,n][d_k, n].
  • SS — the score matrix, shape [n,n][n, n]. SijS_{ij} is how strongly token ii‘s query matches token jj‘s key.

Intuition — similarity as overlap

A dot product is large when two vectors point the same way and near zero when they’re unrelated. So a query “lights up” the keys it resembles. In our toy numbers, the query for flies is aligned with the key for arrow, so qflieskarrowq_{\text{flies}} \cdot k_{\text{arrow}} is the biggest score in that row.

Worked example. With qflies=[0,3,0,0]q_{\text{flies}} = [0,3,0,0] and karrow=[0,3,0,0]k_{\text{arrow}} = [0,3,0,0], the score is 00+33+00+00=90\cdot0 + 3\cdot3 + 0\cdot0 + 0\cdot0 = 9. Against ktime=[2,0,0,1]k_{\text{time}} = [2,0,0,1] it is 00. “flies” strongly prefers “arrow”.

Step 3 — scale, then softmax

Raw dot products can get large, which pushes the softmax into a region where its gradients vanish. So we divide by dk\sqrt{d_k} first, then normalize:

wi=softmax ⁣(Sidk),softmax(z)j=ezjlezlw_i = \operatorname{softmax}\!\left(\frac{S_i}{\sqrt{d_k}}\right), \qquad \operatorname{softmax}(z)_j = \frac{e^{z_j}}{\sum_{l} e^{z_l}}
  • dkd_k — the key/query dimension. Dividing by dk\sqrt{d_k} keeps the variance of the scores around 1 regardless of dimension.
  • softmax\operatorname{softmax} — turns a row of real scores into positive weights that sum to 1.
  • wiw_i — token ii‘s attention distribution over all keys.

Intuition — why divide by √dₖ?

If the entries of qq and kk have unit variance, their dot product over dkd_k dimensions has variance dkd_k — it grows with the vector size. Left unchecked, one score dwarfs the others and softmax becomes a hard argmax with almost no gradient. Dividing by dk\sqrt{d_k} rescales it back to variance ≈ 1, keeping the softmax soft and trainable.

Worked example. The “flies” scores [0,6,0,0,9][0, 6, 0, 0, 9] scaled by 4=2\sqrt{4}=2 become [0,3,0,0,4.5][0, 3, 0, 0, 4.5]. Softmax gives wflies[0.01, 0.18, 0.01, 0.01, 0.80]w_{\text{flies}} \approx [0.01,\ 0.18,\ 0.01,\ 0.01,\ 0.80] — exactly the weights from the averaging example above. Without the ÷2\div 2 scaling, the raw [0,6,0,0,9][0,6,0,0,9] would softmax to a spikier [0.00,0.05,0.00,0.00,0.95]\approx[0.00, 0.05, 0.00, 0.00, 0.95].

Step 4 — weighted sum of values

Finally, mix the value vectors with those weights. In matrix form the whole mechanism is one tidy expression:

Attention(Q,K,V)=softmax ⁣(QKdk)V\operatorname{Attention}(Q, K, V) = \operatorname{softmax}\!\left(\frac{Q K^{\top}}{\sqrt{d_k}}\right) V
  • The softmax term is the [n,n][n, n] weight matrix WW.
  • VV is the values, shape [n,dv][n, d_v].
  • WVW V is the output, shape [n,dv][n, d_v]: row ii is jwjivj\sum_j w_{ji} v_j.

Intuition — retrieve, softly

The supermarket analogy: your recipe’s ingredients are queries; each shelf label is a key; the product is a value. Instead of grabbing the single best match, self-attention takes every value in proportion to how well its key matches the query. “A dozen eggs” might return 10 eggs, an omelette, and a chicken wing.

Try it: the attention lab

Everything above happens live below. Pick a query token and step through the four operations. Watch flies collapse onto arrow at the softmax step.

Query token:

The output is the value vectors averaged by those weights.

q(flies)[0, 3, 0, 0]
key token jk(j)q·k÷√4weight wⱼv(j)
time000.009
flies6.003.000.178
like000.009
an000.009
arrow9.004.500.796

weights sum to 1.00. “flies” attends most to arrow.

x′(flies) =[1.80, 2.13, 0.83, 0.04]← the context-updated embedding
AttentionLab — step through scaled dot-product attention for one query token. Try switching the query to “time” or “like”.

And here is the whole 5×55 \times 5 weight matrix at once — every row is one token’s attention distribution. Notice function words (“like”, “an”) spread their attention, while “flies” concentrates on “arrow”.

key (attended to) →
timeflieslikeanarrow
query↓time
.65
.09
.09
.09
.09
flies
.18
.80
like
.06
.06
.42
.42
.06
an
.06
.06
.42
.42
.06
arrow
.10
.27
.10
.10
.44

Each row is one query token's attention distribution over all keys — it sums to 1. Darker = more attention. Hover a cell for the exact weight.

The full 5×5 attention matrix for “time flies like an arrow”. Row “flies” is dominated by “arrow”.

Implementing it in PyTorch

First tokenize the text into input ids (excluding the [CLS]/[SEP] special tokens for simplicity):

inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False)
inputs.input_ids
# tensor([[ 2051, 10029,  2066,  2019,  8612]])

Then look up a dense embedding for each id with an nn.Embedding layer:

from torch import nn
from transformers import AutoConfig

config = AutoConfig.from_pretrained(model_ckpt)
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)  # Embedding(30522, 768)
inputs_embeds = token_emb(inputs.input_ids)
inputs_embeds.size()
# torch.Size([1, 5, 768])   ->  [batch_size, seq_len, hidden_dim]

Now score, scale, softmax, and mix. Keeping query = key = value = inputs_embeds for simplicity:

import torch
from math import sqrt
import torch.nn.functional as F

query = key = value = inputs_embeds
dim_k = key.size(-1)                                   # 768
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
scores.size()                                          # torch.Size([1, 5, 5])

weights = F.softmax(scores, dim=-1)
weights.sum(dim=-1)                                    # tensor([[1., 1., 1., 1., 1.]])

attn_outputs = torch.bmm(weights, value)
attn_outputs.shape                                     # torch.Size([1, 5, 768])

That’s the entire mechanism — two matrix multiplies and a softmax. Wrapped up as a reusable function:

def scaled_dot_product_attention(query, key, value):
    dim_k = query.size(-1)
    scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
    weights = F.softmax(scores, dim=-1)
    return torch.bmm(weights, value)

Follow the shapes end to end:

input_ids[1,5]
[1,5,768]
[1,5,768]
[1,5,5]
[1,5,5]
[1,5,768]

Click an operation to see what it does. A highlighted dimension is one that just changed.

The shape journey of one attention layer. A highlighted dimension is one that just changed.

Note — PyTorch ↔ TensorFlow

We use PyTorch, but the steps are analogous in TensorFlow/Keras. The classes used in this chapter: nn.Linearkeras.layers.Dense, nn.LayerNormkeras.layers.LayerNormalization, nn.Embeddingkeras.layers.Embedding, nn.GELUkeras.activations.gelu, torch.bmmtf.matmul (batched matmul).

Why three separate vectors?

With query = key = value set equal, a token’s query matches itself perfectly — the dot product of a vector with itself is large — so every token attends mostly to its own position. But a word’s meaning is usually better informed by its neighbours than by a second copy of itself: “flies” is clarified by “time” and “arrow”, not by “flies” again.

The fix is to give the layer freedom: apply three different learned linear projections to each embedding, producing distinct query, key, and value vectors that can specialize. Stacking several such projections in parallel gives multi-headed attention — the subject of the next section.

Check yourself: scaled dot-product attention

1.In self-attention, what is each updated embedding x'ᵢ?

2.Why is the dot product a sensible similarity function for query and key vectors?

3.Why are the attention scores divided by √dₖ before the softmax?

4.Predict the shape: with inputs_embeds of shape [1, 5, 768], what is the shape of `scores = bmm(Q, Kᵀ)/√dₖ`?

5.In the AttentionLab, the query “flies” ends up attending ~0.80 to “arrow”. At which step does that concentration appear?

6.The book first sets query = key = value = the embeddings. What problem does that cause, and how is it fixed?

6 questions