§3.4Self-Attention with Trainable Weights (Scaled Dot-Product Attention)

Part I pp. 29–36 · ~15 min read

  • query
  • key
  • value
  • scaled dot-product attention

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 . Rung 2 of the chapter’s ladder:

1) Simplifiedself-attention ✓2) Self-attention+ trainable weights ← now3) Causal attention4) Multi-head attention
This section’s job (the book’s p. 29 roadmap): add the functionality of updating weights during training, so the model learns to construct better context vectors.

3.4.1 Queries, keys, and values — computed step by step

Three trainable weight matrices enter: WqW_q, WkW_k, WvW_v. Each one projects an embedded input token x(i)x^{(i)} into a different role:

q(i)=x(i)Wqk(i)=x(i)Wkv(i)=x(i)Wvq^{(i)} = x^{(i)} W_q \qquad k^{(i)} = x^{(i)} W_k \qquad v^{(i)} = x^{(i)} W_v

symbolmeaningshape
x(i)x^{(i)}input embedding of token ii(d_in,) = (3,)
Wq,Wk,WvW_q, W_k, W_vtrainable projection matrices (“weight parameters”)(d_in × d_out) = (3×2)
q(i)q^{(i)} query — the probe, when token ii is the one looking(d_out,) = (2,)
k(i)k^{(i)} key — what token ii exposes to be matched against(d_out,) = (2,)
v(i)v^{(i)} value — the content token ii 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):

"Your" x⁽¹⁾ 0.40.10.8 W_k W_v 0.30.7 key k⁽¹⁾ 0.10.8 value v⁽¹⁾ "journey" x⁽²⁾ ← the query token 0.50.80.6 W_q W_k W_v 0.41.4 query q⁽²⁾ 0.41.1 k⁽²⁾ 0.31.0 v⁽²⁾ "step" x⁽ᵀ⁾ 0.00.80.5 W_k W_v 0.30.9 k⁽ᵀ⁾ 0.30.7 v⁽ᵀ⁾ ω₂₁ = 1.27 q⁽²⁾ · k⁽¹⁾ — query meets key 1 ω₂₂ = 1.85 ω₂ᵀ = 1.54 one score per key — the full row ω₂ = q⁽²⁾ · keysᵀ
1

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.

1 / 5

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».

</> Initializing W_q, W_k, W_v and projecting x²
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 (WqW_q, WkW_k, WvW_v — often just “weights”) are the learned coefficients of the network, optimized during training and then fixed. Attention weights (α\alpha) 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 z(2)z^{(2)} here, the query must be matched against — and retrieve from — every token, so all keys and values are computed in one shot:

</> All keys and values via matrix multiplication
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 jj, and its scaled softmax normalization:

ω2j=q(2)k(j)α2j=softmax ⁣(ω2jdk)\omega_{2j} = q^{(2)} \cdot k^{(j)} \qquad\qquad \alpha_{2j} = \mathrm{softmax}\!\left(\frac{\omega_{2j}}{\sqrt{d_k}}\right)

symbolmeaningshape
ω2j\omega_{2j}unscaled attention score (query 2 vs. key jj)scalar
dkd_kdimension of the key vectors (here 2)
dk\sqrt{d_k}the scaling divisor — here 21.41\sqrt{2} \approx 1.41
α2j\alpha_{2j}attention weight; row sums to 1scalar

Worked example: ω22=q(2)k(2)=1.8524\omega_{22} = q^{(2)} \cdot k^{(2)} = 1.8524, and the full row is ω2=[1.2705,1.8524,1.8111,1.0795,0.5577,1.5440]\omega_2 = [1.2705, 1.8524, 1.8111, 1.0795, 0.5577, 1.5440]. Dividing by 2\sqrt{2} and applying softmax gives the weights the book prints — verify below, where the exact numbers are reproduced live:

</> Attention score ω₂₂, the full score row, and scaled softmax
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.
attention scores ω₂ (from learned projections)sum = 8.12 (arbitrary)
Your
1.2705
journey
1.8524
starts
1.8111
with
1.0795
one
0.5577
step
1.5440
softmax(x / √d_k)sum = 1, all positive
Your
0.1500
journey
0.2264
starts
0.2199
with
0.1311
one
0.0906
step
0.1820
softmax(ω₂/√2) reproduces the book's weights exactly: [0.1500, 0.2264, 0.2199, 0.1311, 0.0906, 0.1820].

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 dk\sqrt{d_k} 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 TT mimics what unscaled, large dot products do to softmax (one bar takes everything: a step function), large TT what over-scaling would do (all bars equal: no signal). dk\sqrt{d_k} sits in the healthy middle:

temperature T = 1.0
attention scores ω₂sum = 8.12 (arbitrary)
Your
1.2705
journey
1.8524
starts
1.8111
with
1.0795
one
0.5577
step
1.5440
softmax(x / 1.0)sum = 1, all positive
Your
0.1401
journey
0.2507
starts
0.2406
with
0.1157
one
0.0687
step
0.1842
Drag toward T = 0.1: softmax sharpens into a near-step function (vanishing gradients for the losers). Drag toward T = 5: it flattens toward uniform. Scaling keeps attention in between.

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:

z(2)=j=1Tα2jv(j)=α2valuesz^{(2)} = \sum_{j=1}^{T} \alpha_{2j}\, v^{(j)} = \alpha_2 \cdot \text{values}

Worked example: z(2)=0.1500v(1)+0.2264v(2)++0.1820v(6)=[0.3061,0.8210]z^{(2)} = 0.1500{\cdot}v^{(1)} + 0.2264{\cdot}v^{(2)} + \dots + 0.1820{\cdot}v^{(6)} = [0.3061, 0.8210] — note it is 2-dimensional now (dout=2d_{out}=2), no longer 3-dimensional like the inputs.

</> The context vector z²
context_vec_2 = attn_weights_2 @ values
print(context_vec_2)
# tensor([0.3061, 0.8210])
z⁽²⁾ (1×2)
0.31
0.82
dim 0dim 1
The trainable-attention context vector for «journey». Compare §3.3's untrained z⁽²⁾ = [0.4419, 0.6515, 0.5683]: different dimensionality and values, same role — an enriched representation.

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 WvW_v 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:

</> Listing 3.1 — A compact self-attention class
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 z(2)=[0.3061,0.8210]z^{(2)} = [0.3061, 0.8210] computed by hand — the class runs the identical dataflow for all six queries at once:

x(6×3)W_qW_kW_vqueries Q(6×2)keys K(6×2)values V(6×2)ω = Q·Kᵀscores (6×6)softmax(·/√d_k)weights α(6×6), rows sum 1Z = α·Vcontext vectors (6×2)
The forward pass as a dataflow: three learned projections, one score matmul, one scaled row-softmax, one blend over the values. The Q/K/V colors (blue/emerald/amber) recur in every attention diagram from here on.

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:

</> Listing 3.2 — Self-attention using PyTorch's Linear layers
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 WW matrices. After training, initialization differences wash out; before training, they fully determine the outputs.

Key idea — the whole mechanism in one line

Z=softmax ⁣(QKdk)VZ = \mathrm{softmax}\!\left(\frac{Q K^\top}{\sqrt{d_k}}\right) V with Q=XWqQ = XW_q, K=XWkK = XW_k, V=XWvV = XW_v. 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 / 5
  1. 1.What do the three trainable matrices W_q, W_k, W_v actually do?

  2. 2.Why are the attention scores divided by √d_k before the softmax?

  3. 3.The book warns: don't confuse weight parameters with attention weights. What's the difference?

  4. 4.Predict: with query q⁽²⁾ = [0.4306, 1.4551] and key k⁽²⁾ = [0.4433, 1.1419], what is the attention score ω₂₂?

  5. 5.Why does SelfAttention_v2 use nn.Linear layers instead of nn.Parameter(torch.rand(...)) as in v1?