This section computes attention by hand, with zero trainable weights — the purest form of the mechanism. Once the moving parts are visible, §3.4 will add the trainable matrices, and nothing conceptual will change.
Recall the “self” from the previous section: the mechanism relates positions within one sequence to each other — every word of a sentence asks “how relevant is every other word to me?” — in contrast to traditional attention, which connects two different sequences (like the German input and English output of a translator).
3.3.1 One query, step by step
The running example is the six-token sentence “Your journey starts with one step”, where each token already has a 3-dimensional embedding from chapter 2’s pipeline (the book uses 3 dimensions so every number fits on screen; GPT-2 uses 768):
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)
) The goal: for the second token, (“journey”), compute a context vector context vector The weighted sum over all tokens' (value) vectors — an enriched representation of one token that incorporates information from the whole sequence. defined in ch. 3 — open in glossary — an enriched embedding of “journey” that also contains information from through , weighted by relevance. This is what self-attention is for: building, for every element of a sequence, a representation that incorporates information from all the other elements. Later, trainable weights will let the LLM learn to build context vectors that are useful for generating the next token.
Step 1 — attention scores via dot products
The query is . Its attention score attention score ω — the raw query·key dot product between two tokens, before normalization. defined in ch. 3 — open in glossary toward each token is simply the dot product dot product Element-wise multiplication of two vectors followed by summing — a measure of how aligned (similar) the vectors are. defined in ch. 3 — open in glossary :
| symbol | meaning | shape |
|---|---|---|
| attention score of query 2 toward token (ω = “omega”, pre-normalization) | scalar | |
| the query — “journey“‘s embedding | (3,) | |
| the embedding of token | (3,) | |
| dimension index of the embeddings | 1…3 |
Worked example for (query “journey” · token “Your”): .
query = inputs[1] #A
attn_scores_2 = torch.empty(inputs.shape[0])
for i, x_i in enumerate(inputs):
attn_scores_2[i] = torch.dot(x_i, query)
print(attn_scores_2)
# tensor([0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865]) - #A Python indexing is 0-based, so inputs[1] is the SECOND token, x² ("journey").
The same six numbers, computed as one matrix product — hover each result cell to watch its row·column dot product happen:
| journey | 0.55 | 0.87 | 0.66 |
| Your | journey | starts | with | one | step |
0.43 | 0.55 | 0.57 | 0.22 | 0.77 | 0.05 |
0.15 | 0.87 | 0.85 | 0.58 | 0.25 | 0.80 |
0.89 | 0.66 | 0.64 | 0.33 | 0.10 | 0.55 |
0.95 | 1.50 | 1.48 | 0.84 | 0.71 | 1.09 |
Key idea — the dot product measures similarity
Beyond being a way to multiply vectors into a scalar, the dot product quantifies how aligned two vectors are: the higher it is, the more similar the vectors. In self-attention, that similarity is the attention: the higher the dot product, the more one token attends to the other. “journey” scores highest with itself (1.4950) and with “starts” (1.4754) — whose embedding points in almost the same direction.Step 2 — normalize the scores into attention weights
Raw scores have an arbitrary sum. To use them as mixing proportions we normalize them into attention weights attention weight α — the softmax-normalized attention scores; each query's row of weights sums to 1. defined in ch. 3 — open in glossary (alpha) that sum to 1. The obvious way — divide by the sum — works, but in practice the softmax softmax Exponentiate-and-normalize: turns a vector of scores into positive probabilities that sum to 1. defined in ch. 3 — open in glossary function is used:
| symbol | meaning | shape |
|---|---|---|
| attention weight of query 2 on token — how much depends on | scalar, row sums to 1 | |
| exponentiation — makes every weight positive, whatever the score’s sign | scalar | |
| sequence length (here 6) | — |
Compare the two normalizations on the real numbers — hover a token to track it across the charts:
attn_weights_2_tmp = attn_scores_2 / attn_scores_2.sum()
print("Attention weights:", attn_weights_2_tmp)
# Attention weights: tensor([0.1455, 0.2278, 0.2249, 0.1285, 0.1077, 0.1656])
def softmax_naive(x):
return torch.exp(x) / torch.exp(x).sum(dim=0) #A
attn_weights_2_naive = softmax_naive(attn_scores_2)
print("Attention weights:", attn_weights_2_naive)
# Attention weights: tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581])
attn_weights_2 = torch.softmax(attn_scores_2, dim=0) #B
print("Attention weights:", attn_weights_2)
# Attention weights: tensor([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581]) - #A Works, but this naive implementation can overflow/underflow for large or small inputs — exp() grows fast.
- #B torch.softmax is implemented to avoid those numerical-instability problems. Same math, safe execution — use this one.
Why softmax and not plain division?
Two reasons. Positivity: scores can be negative; softmax’s exponentials make every weight positive, so weights behave like proportions. Training behavior: softmax’s gradient properties are better for optimization when, in §3.4, these weights sit inside a network that learns. The sum-to-1 property also makes weights interpretable — α₂₂ ≈ 0.24 literally means “24% of comes from «journey» itself”.Step 3 — the context vector is a weighted sum
Multiply each input vector by its attention weight, then add everything up:
Scale the first input by its weight
α₂₁ = 0.1385, so "Your" contributes 0.1385 · [0.43, 0.15, 0.89] = [0.0596, 0.0208, 0.1233] to the context vector. A small weight → a small contribution.
Building z⁽²⁾ = Σ α₂ᵢ·x⁽ⁱ⁾ — every token contributes its embedding, scaled by its attention weight.
query = inputs[1] # 2nd input token is the query
context_vec_2 = torch.zeros(query.shape)
for i, x_i in enumerate(inputs):
context_vec_2 += attn_weights_2[i] * x_i
print(context_vec_2)
# tensor([0.4419, 0.6515, 0.5683]) 3.3.2 All queries at once — three matmuls, no loops
Nothing about “journey” was special: every token wants its own context vector.
Instead of looping, compute all pairwise scores in one matrix
multiplication — inputs @ inputs.T puts the dot product in cell of a 6×6 matrix. Hover the result to see each cell’s
row·column origin:
| Your | 0.43 | 0.15 | 0.89 |
| journey | 0.55 | 0.87 | 0.66 |
| starts | 0.57 | 0.85 | 0.64 |
| with | 0.22 | 0.58 | 0.33 |
| one | 0.77 | 0.25 | 0.10 |
| step | 0.05 | 0.80 | 0.55 |
| Your | journey | starts | with | one | step |
0.43 | 0.55 | 0.57 | 0.22 | 0.77 | 0.05 |
0.15 | 0.87 | 0.85 | 0.58 | 0.25 | 0.80 |
0.89 | 0.66 | 0.64 | 0.33 | 0.10 | 0.55 |
1.00 | 0.95 | 0.94 | 0.48 | 0.46 | 0.63 |
0.95 | 1.50 | 1.48 | 0.84 | 0.71 | 1.09 |
0.94 | 1.48 | 1.46 | 0.83 | 0.72 | 1.06 |
0.48 | 0.84 | 0.83 | 0.49 | 0.35 | 0.66 |
0.46 | 0.71 | 0.72 | 0.35 | 0.67 | 0.29 |
0.63 | 1.09 | 1.06 | 0.66 | 0.29 | 0.95 |
Then softmax each row (dim=1 — normalize across the last dimension, so
every query’s weights sum to 1 independently). Toggle between the raw scores
and the normalized weights:
| Your | journey | starts | with | one | step | |
| Your | 1.00 | 0.95 | 0.94 | 0.48 | 0.46 | 0.63 |
| journey | 0.95 | 1.50 | 1.48 | 0.84 | 0.71 | 1.09 |
| starts | 0.94 | 1.48 | 1.46 | 0.83 | 0.72 | 1.06 |
| with | 0.48 | 0.84 | 0.83 | 0.49 | 0.35 | 0.66 |
| one | 0.46 | 0.71 | 0.72 | 0.35 | 0.67 | 0.29 |
| step | 0.63 | 1.09 | 1.06 | 0.66 | 0.29 | 0.94 |
attn_scores = inputs @ inputs.T
attn_weights = torch.softmax(attn_scores, dim=1) #A
all_context_vecs = attn_weights @ inputs #B
print(all_context_vecs)
# tensor([[0.4421, 0.5931, 0.5790],
# [0.4419, 0.6515, 0.5683],
# [0.4431, 0.6496, 0.5671],
# [0.4304, 0.6298, 0.5510],
# [0.4671, 0.5910, 0.5266],
# [0.4177, 0.6503, 0.5645]]) - #A dim=1 normalizes ACROSS COLUMNS within each row — each query’s six weights sum to 1 independently.
- #B One more matmul: each row of attn_weights (a query’s mixing proportions) times inputs gives that query’s context vector.
The final matmul is the “weighted sum” of step 3, done for all queries at once — hover row 1 (the “journey” row) and confirm it rebuilds :
| Your | 0.21 | 0.20 | 0.20 | 0.12 | 0.12 | 0.15 |
| journey | 0.14 | 0.24 | 0.23 | 0.12 | 0.11 | 0.16 |
| starts | 0.14 | 0.24 | 0.23 | 0.12 | 0.11 | 0.16 |
| with | 0.14 | 0.21 | 0.20 | 0.15 | 0.13 | 0.17 |
| one | 0.15 | 0.20 | 0.20 | 0.14 | 0.19 | 0.13 |
| step | 0.14 | 0.22 | 0.21 | 0.14 | 0.10 | 0.19 |
0.43 | 0.15 | 0.89 |
0.55 | 0.87 | 0.66 |
0.57 | 0.85 | 0.64 |
0.22 | 0.58 | 0.33 |
0.77 | 0.25 | 0.10 |
0.05 | 0.80 | 0.55 |
0.44 | 0.59 | 0.58 |
0.44 | 0.65 | 0.57 |
0.44 | 0.65 | 0.57 |
0.43 | 0.63 | 0.55 |
0.47 | 0.59 | 0.53 |
0.42 | 0.65 | 0.56 |
Key idea — the three-step recipe
1) Attention scores — dot products between the inputs. 2) Attention weights — the scores, softmax-normalized per row. 3) Context vectors — each token’s weighted sum over all inputs. Everything that follows in this chapter (trainable weights in §3.4, causal masking, multiple heads) is a refinement of exactly these three steps.📝 Check yourself: simple self-attention
0 / 51.In the simplified self-attention of this section, how is the attention score ω₂ᵢ between the query "journey" and another token computed?
2.Predict: in the softmax-normalized attention weights for the query "journey", which token receives the HIGHEST weight?
3.Why does the book prefer softmax over simply dividing each score by the sum of scores?
4.What exactly is the context vector z⁽²⁾?
5.In the one-shot matrix version, attn_scores = inputs @ inputs.T. What does cell [i][j] of the resulting 6×6 matrix hold?