§3.3Simple Self-Attention Without Trainable Weights

Part I pp. 24–28 · ~9 min read

  • context vector
  • attention score
  • attention weight
  • dot product
  • softmax

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

</> The input sequence: six 3-D embedding vectors
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, x(2)x^{(2)} (“journey”), compute a context vector z(2)z^{(2)} — an enriched embedding of “journey” that also contains information from x(1)x^{(1)} through x(T)x^{(T)}, 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 x(2)x^{(2)}. Its attention score toward each token x(i)x^{(i)} is simply the dot product :

ω2i=x(2)x(i)=d=13xd(2)xd(i)\omega_{2i} = x^{(2)} \cdot x^{(i)} = \sum_{d=1}^{3} x^{(2)}_d \, x^{(i)}_d

symbolmeaningshape
ω2i\omega_{2i}attention score of query 2 toward token ii (ω = “omega”, pre-normalization)scalar
x(2)x^{(2)}the query — “journey“‘s embedding(3,)
x(i)x^{(i)}the embedding of token ii(3,)
dddimension index of the embeddings1…3

Worked example for ω21\omega_{21} (query “journey” · token “Your”): 0.550.43+0.870.15+0.660.89=0.2365+0.1305+0.5874=0.95440.55{\cdot}0.43 + 0.87{\cdot}0.15 + 0.66{\cdot}0.89 = 0.2365 + 0.1305 + 0.5874 = 0.9544.

</> Attention scores for query x² — the loop version
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:

(1×3)
journey
0.55
0.87
0.66
·
inputsᵀ (3×6)
Yourjourneystartswithonestep
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×6)
0.95
1.50
1.48
0.84
0.71
1.09
hover a cell of ω₂ to see which row·column builds it
ω₂ = x² · inputsᵀ. Each score is one dot product between «journey»'s embedding and another token's embedding.

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 α\alpha (alpha) that sum to 1. The obvious way — divide by the sum — works, but in practice the softmax function is used:

α2i=softmax(ω2i)=eω2ij=1Teω2j\alpha_{2i} = \mathrm{softmax}(\omega_{2i}) = \frac{e^{\omega_{2i}}}{\sum_{j=1}^{T} e^{\omega_{2j}}}

symbolmeaningshape
α2i\alpha_{2i}attention weight of query 2 on token ii — how much z(2)z^{(2)} depends on x(i)x^{(i)}scalar, row sums to 1
eωe^{\omega}exponentiation — makes every weight positive, whatever the score’s signscalar
TTsequence length (here 6)

Compare the two normalizations on the real numbers — hover a token to track it across the charts:

attention scores ω₂sum = 6.56 (arbitrary)
Your
0.9544
journey
1.4950
starts
1.4754
with
0.8434
one
0.7070
step
1.0865
naive x / Σxsum = 1, but negatives stay negative
Your
0.1455
journey
0.2278
starts
0.2249
with
0.1285
one
0.1077
step
0.1656
softmax(x)sum = 1, all positive
Your
0.1385
journey
0.2379
starts
0.2333
with
0.1240
one
0.1082
step
0.1581
Both normalizations sum to 1, but softmax exponentiates first: always-positive weights, sharper contrast between high and low scores, and better gradient behavior for training.
</> Naive sum-normalization, naive softmax, and the PyTorch softmax
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 z(2)z^{(2)} 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:

z(2)=i=1Tα2ix(i)z^{(2)} = \sum_{i=1}^{T} \alpha_{2i}\, x^{(i)}

x⁽1⁾ Your α=0.14 α·x⁽1⁾ x⁽2⁾ journey α=0.24 α·x⁽2⁾ x⁽3⁾ starts α=0.23 α·x⁽3⁾ x⁽4⁾ with α=0.12 α·x⁽4⁾ x⁽5⁾ one α=0.11 α·x⁽5⁾ x⁽6⁾ step α=0.16 α·x⁽6⁾ z⁽²⁾ = [0.44, 0.65, 0.57] Σ — element-wise sum of the six scaled vectors
1

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.

1 / 3

Building z⁽²⁾ = Σ α₂ᵢ·x⁽ⁱ⁾ — every token contributes its embedding, scaled by its attention weight.

</> Computing the context vector z²
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 x(i)x(j)x^{(i)} \cdot x^{(j)} in cell [i,j][i,j] of a 6×6 matrix. Hover the result to see each cell’s row·column origin:

inputs (6×3)
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
·
inputsᵀ (3×6)
Yourjourneystartswithonestep
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
=
attn_scores (6×6)
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
hover a cell of attn_scores to see which row·column builds it
attn_scores = inputs @ inputs.T — all 36 attention scores in one operation. Row 2 reproduces the ω₂ vector computed one dot product at a time above.

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:

Yourjourneystartswithonestep
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
hover a cell — row = query (who is looking), column = key (who is looked at)
The full attention matrix: row = query token (who is looking), column = key token (who is being looked at). Switch to «softmax weights α» — each row then sums to 1. The diagonal is bright: every token is most similar to itself.
</> Scores → weights → context vectors, for all six tokens
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 z(2)=[0.4419,0.6515,0.5683]z^{(2)} = [0.4419, 0.6515, 0.5683]:

attn_weights (6×6)
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
·
inputs (6×3)
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
=
context_vecs (6×3)
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
hover a cell of context_vecs to see which row·column builds it
all_context_vecs = attn_weights @ inputs. Each output row is one token's context vector: its row of attention weights blended over the six input embeddings.

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 / 5
  1. 1.In the simplified self-attention of this section, how is the attention score ω₂ᵢ between the query "journey" and another token computed?

  2. 2.Predict: in the softmax-normalized attention weights for the query "journey", which token receives the HIGHEST weight?

  3. 3.Why does the book prefer softmax over simply dividing each score by the sum of scores?

  4. 4.What exactly is the context vector z⁽²⁾?

  5. 5.In the one-shot matrix version, attn_scores = inputs @ inputs.T. What does cell [i][j] of the resulting 6×6 matrix hold?