The simplified mechanism of §3.3 had nothing to learn — its attention weights came straight from fixed embedding similarities. This section adds the one ingredient a real LLM needs: trainable weight matrices, so the model can learn which similarities matter for predicting the next token. The result is the mechanism actual GPTs use, called scaled dot-product attention scaled dot-product attention softmax(QKᵀ/√d_k)·V — self-attention with trainable projections; dividing by √d_k keeps softmax out of its vanishing-gradient regime at large dimensions. defined in ch. 3 — open in glossary . Rung 2 of the chapter’s ladder:
3.4.1 Queries, keys, and values — computed step by step
Three trainable weight matrices enter: , , . Each one projects an embedded input token into a different role:
| symbol | meaning | shape |
|---|---|---|
| input embedding of token | (d_in,) = (3,) | |
| trainable projection matrices (“weight parameters”) | (d_in × d_out) = (3×2) | |
| query query The projection x·W_q of the token currently probing the sequence — it is matched against every key to decide where to attend. defined in ch. 3 — open in glossary — the probe, when token is the one looking | (d_out,) = (2,) | |
| key key The projection x·W_k each token exposes to be matched against queries when computing attention scores. defined in ch. 3 — open in glossary — what token exposes to be matched against | (d_out,) = (2,) | |
| value value The projection x·W_v carrying the content that is actually retrieved and mixed into the context vector, weighted by attention. defined in ch. 3 — open in glossary — the content token contributes when attended to | (d_out,) = (2,) |
In GPT-like models the input and output dimensions are usually the same; the
book picks different ones (d_in=3, d_out=2) purely so you can follow which
matrix produced which vector. Step through the projections exactly as the
book’s figure draws them (cell values rounded to 1 decimal, as in the book):
Every token gets a key and a value
x⁽¹⁾ ("Your") is multiplied by W_k and W_v, producing its key k⁽¹⁾ and value v⁽¹⁾ — both 2-dimensional because d_out=2. Even though we only want z⁽²⁾, EVERY token needs its key and value: they are what the query will be matched against and what it will retrieve.
Every token gets a key and a value; the QUERY is only computed for the token we're building a context vector for — here x⁽²⁾, «journey».
import torch
inputs = torch.tensor(
[[0.43, 0.15, 0.89], # Your (x^1)
[0.55, 0.87, 0.66], # journey (x^2)
[0.57, 0.85, 0.64], # starts (x^3)
[0.22, 0.58, 0.33], # with (x^4)
[0.77, 0.25, 0.10], # one (x^5)
[0.05, 0.80, 0.55]] # step (x^6)
)
x_2 = inputs[1] #A
d_in = inputs.shape[1] #B
d_out = 2 #C
torch.manual_seed(123)
W_query = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False) #D
W_key = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False)
W_value = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False)
query_2 = x_2 @ W_query
key_2 = x_2 @ W_key
value_2 = x_2 @ W_value
print(query_2)
# tensor([0.4306, 1.4551]) - #A The second input token, "journey", is our query token.
- #B d_in = 3, the embedding size of the inputs.
- #C d_out = 2 — deliberately different from d_in so the two spaces are easy to tell apart. In real GPT models d_in = d_out.
- #D requires_grad=False only to reduce clutter in the printed outputs. For actual training these MUST be requires_grad=True so the optimizer can update them.
Don’t confuse weight parameters with attention weights
Weight parameters (, , — often just “weights”) are the learned coefficients of the network, optimized during training and then fixed. Attention weights () are dynamic, context-specific values — recomputed for every input sequence, they determine to what extent the context vector depends on each part of the input. One defines the network’s connections; the other is what the network computes on the fly.Although we only want here, the query must be matched against — and retrieve from — every token, so all keys and values are computed in one shot:
keys = inputs @ W_key
values = inputs @ W_value
print("keys.shape:", keys.shape)
# keys.shape: torch.Size([6, 2]) From scores to scaled weights
The attention score between query 2 and key , and its scaled softmax normalization:
| symbol | meaning | shape |
|---|---|---|
| unscaled attention score (query 2 vs. key ) | scalar | |
| dimension of the key vectors (here 2) | — | |
| the scaling divisor — here | — | |
| attention weight; row sums to 1 | scalar |
Worked example: , and the full row is . Dividing by and applying softmax gives the weights the book prints — verify below, where the exact numbers are reproduced live:
keys_2 = keys[1] #A
attn_score_22 = query_2.dot(keys_2)
print(attn_score_22)
# tensor(1.8524)
attn_scores_2 = query_2 @ keys.T # all scores for this query
print(attn_scores_2)
# tensor([1.2705, 1.8524, 1.8111, 1.0795, 0.5577, 1.5440])
d_k = keys.shape[-1]
attn_weights_2 = torch.softmax(attn_scores_2 / d_k**0.5, dim=-1) #B
print(attn_weights_2)
# tensor([0.1500, 0.2264, 0.2199, 0.1311, 0.0906, 0.1820]) - #A The key of token 2 itself — matching the query against it gives ω₂₂.
- #B The one visible difference from §3.3: scores are divided by √d_k = √2 before the softmax.
Key idea — why divide by √d_k?
As the embedding dimension grows (thousands, in GPT-4-class models), dot products grow large. Softmax applied to large values behaves like a step function: nearly all probability mass lands on one entry, every other entry’s gradient approaches zero, and training slows or stagnates. Scaling by keeps the dot products in a range where softmax stays smooth, gradients stay healthy, and learning proceeds — this is why the mechanism is named scaled dot-product attention.Feel the effect yourself. The temperature slider below divides the same scores before softmax — small mimics what unscaled, large dot products do to softmax (one bar takes everything: a step function), large what over-scaling would do (all bars equal: no signal). sits in the healthy middle:
The context vector, now built from values
The final step mirrors §3.3, but the weighted sum runs over the value vectors, not the raw inputs:
Worked example: — note it is 2-dimensional now (), no longer 3-dimensional like the inputs.
context_vec_2 = attn_weights_2 @ values
print(context_vec_2)
# tensor([0.3061, 0.8210]) 0.31 | 0.82 |
| dim 0 | dim 1 |
Why “query”, “key”, and “value”? — the retrieval analogy
The names come from information retrieval and databases. The query is the item the model is currently focusing on — it probes the other parts to decide how much attention each deserves. Every token exposes a key, matched against the query to find the most relevant parts. Once matched, the model retrieves the corresponding value — the actual content that gets blended into the context vector. Why can’t the values just be the raw inputs? Because is what lets the model choose what content to hand over when attended to, separately from how findable it is (the key). Collapsing the two into the raw input would rob the mechanism of that distinction and reduce its expressive power — especially for subtle, position-dependent features in long sequences.3.4.2 A compact self-attention class
Everything above, bundled into a PyTorch module — this is the book’s Listing 3.1:
import torch.nn as nn
class SelfAttention_v1(nn.Module):
def __init__(self, d_in, d_out):
super().__init__()
self.d_out = d_out
self.W_query = nn.Parameter(torch.rand(d_in, d_out))
self.W_key = nn.Parameter(torch.rand(d_in, d_out))
self.W_value = nn.Parameter(torch.rand(d_in, d_out))
def forward(self, x):
keys = x @ self.W_key
queries = x @ self.W_query
values = x @ self.W_value
attn_scores = queries @ keys.T #A
attn_weights = torch.softmax(
attn_scores / keys.shape[-1]**0.5, dim=-1) #B
context_vec = attn_weights @ values
return context_vec
torch.manual_seed(123)
sa_v1 = SelfAttention_v1(d_in, d_out)
print(sa_v1(inputs))
# tensor([[0.2996, 0.8053],
# [0.3061, 0.8210],
# [0.3058, 0.8203],
# [0.2948, 0.7939],
# [0.2927, 0.7891],
# [0.2990, 0.8040]], grad_fn=<MmBackward0>) - #A The Greek-letter omega (ω) matrix: ALL queries against ALL keys in one 6×6 matmul — same trick as §3.3.2.
- #B Scaled softmax per row: keys.shape[-1] is d_k, and dim=-1 normalizes each query’s row.
Row 1 of the output is exactly the computed by hand — the class runs the identical dataflow for all six queries at once:
Listing 3.2 — the same class with nn.Linear
Using nn.Linear (with bias=False) computes the same projections, but its
optimized weight-initialization scheme makes training more stable and
effective than raw torch.rand parameters:
class SelfAttention_v2(nn.Module):
def __init__(self, d_in, d_out, qkv_bias=False):
super().__init__()
self.d_out = d_out
self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias) #A
self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
def forward(self, x):
keys = self.W_key(x)
queries = self.W_query(x)
values = self.W_value(x)
attn_scores = queries @ keys.T
attn_weights = torch.softmax(
attn_scores / keys.shape[-1]**0.5, dim=-1)
context_vec = attn_weights @ values
return context_vec
torch.manual_seed(789)
sa_v2 = SelfAttention_v2(d_in, d_out)
print(sa_v2(inputs))
# tensor([[-0.0739, 0.0713],
# [-0.0748, 0.0703],
# [-0.0749, 0.0702],
# [-0.0760, 0.0685],
# [-0.0763, 0.0679],
# [-0.0754, 0.0693]], grad_fn=<MmBackward0>) - #A qkv_bias=False makes nn.Linear a pure matrix multiplication — mathematically identical to v1, just better initialized. The qkv_bias flag reappears in the GPT config of ch4.
Why do v1 and v2 print different numbers?
Same architecture, same math — different initial weights.nn.Linear uses
a more sophisticated initialization scheme than torch.rand, so even with
seeds set, the two classes start from different matrices. After training,
initialization differences wash out; before training, they fully determine the
outputs.Key idea — the whole mechanism in one line
with , , . Three learned projections, a scaled row-softmax, a blend over values. §3.5 adds one more ingredient — a mask that stops queries from looking at future tokens — and that is the attention a GPT actually runs.📝 Check yourself: trainable self-attention
0 / 51.What do the three trainable matrices W_q, W_k, W_v actually do?
2.Why are the attention scores divided by √d_k before the softmax?
3.The book warns: don't confuse weight parameters with attention weights. What's the difference?
4.Predict: with query q⁽²⁾ = [0.4306, 1.4551] and key k⁽²⁾ = [0.4433, 1.1419], what is the attention score ω₂₂?
5.Why does SelfAttention_v2 use nn.Linear layers instead of nn.Parameter(torch.rand(...)) as in v1?