Build Your Own LLM, Lesson 5: Self-Attention From Scratch

⏱ 4 min readUpdated 27 September 2026

🧠 Lesson 5 of 12 · Build Your Own LLM course

In this article
  1. The intuition: a soft lookup
  2. Attention on four tokens with NumPy
  3. Three details that matter
  4. The same thing in PyTorch
  5. Position: attention alone is order-blind
  6. Why this changed everything
  7. Coming next

Our models so far predict the next token from one previous token. Real language needs more: in “The files that the manager sent were…”, the verb depends on files, five tokens back. Self-attention lets every token gather information from any earlier token β€” and learn which ones matter.

The intuition: a soft lookup

Think of each token asking a question and every earlier token offering an answer:

  • Query (Q) β€” what am I looking for? (“I’m a verb; where is my subject?”)
  • Key (K) β€” what do I contain? (“I’m a plural noun.”)
  • Value (V) β€” what do I pass on if someone attends to me?

Each token compares its query with every key (a dot product), turns the scores into weights that add up to 1 (softmax), and takes a weighted average of the values. It is like an Excel lookup where, instead of one exact match, you get a blend of all rows weighted by how well they match.

Attention on four tokens with NumPy

“`python
import numpy as np
np.random.seed(0)

T, d = 4, 8 # 4 tokens, 8 numbers each
x = np.random.randn(T, d) # token embeddings (from Lesson 4)
Wq, Wk, Wv = (np.random.randn(d, d) / np.sqrt(d) for _ in range(3))

Q, K, V = x @ Wq, x @ Wk, x @ Wv # each (4, 8)
scores = Q @ K.T / np.sqrt(d) # (4, 4): token i vs token j

mask = np.triu(np.ones((T, T)), k=1).astype(bool)
scores[mask] = -np.inf # no peeking at future tokens

weights = np.exp(scores – scores.max(axis=1, keepdims=True))
weights /= weights.sum(axis=1, keepdims=True) # softmax per row
out = weights @ V # (4, 8): new token vectors
print(weights.round(2))
“`

The printed weights form a lower-triangular matrix: row 1 can only attend to token 1, row 4 can attend to tokens 1–4, and every row sums to 1.

Three details that matter

Detail Why
/ np.sqrt(d) Dot products grow with vector length. Without scaling, softmax becomes extremely peaked and training stalls.
Causal mask While training, the model sees a whole sentence at once. The mask stops token 3 from reading token 4 β€” otherwise predicting the next token would be cheating.
Subtract the max before exp Numerical safety: it avoids overflow and does not change the softmax result.

The same thing in PyTorch

In a real model, Wq, Wk and Wv are learned by gradient descent, and we process a batch of sequences at once:

“`python
import torch
import torch.nn as nn
import torch.nn.functional as F

class Head(nn.Module):
“””One head of causal self-attention.”””
def __init__(self, d_model, d_head, block_size):
super().__init__()
self.q = nn.Linear(d_model, d_head, bias=False)
self.k = nn.Linear(d_model, d_head, bias=False)
self.v = nn.Linear(d_model, d_head, bias=False)
self.register_buffer(“mask”, torch.tril(torch.ones(block_size, block_size)))

def forward(self, x): # x: (batch, T, d_model)
B, T, _ = x.shape
q, k, v = self.q(x), self.k(x), self.v(x)
att = q @ k.transpose(-2, -1) / k.shape[-1] ** 0.5 # (B, T, T)
att = att.masked_fill(self.mask[:T, :T] == 0, float(“-inf”))
att = F.softmax(att, dim=-1)
return att @ v # (B, T, d_head)

head = Head(d_model=32, d_head=16, block_size=64)
x = torch.randn(2, 10, 32) # 2 sequences, 10 tokens each
print(head(x).shape) # torch.Size([2, 10, 16])
“`

Position: attention alone is order-blind

Attention compares every pair of tokens but has no idea which came first β€” “dog bites man” and “man bites dog” would look the same. Models fix this by adding a position embedding to each token embedding:

“`python
tok_emb = nn.Embedding(V, 32)
pos_emb = nn.Embedding(64, 32) # one vector per position
x = tok_emb(idx) + pos_emb(torch.arange(idx.shape[1]))
“`

Many modern models use rotary position embeddings (RoPE) instead, but the learned version above is what GPT-2 used and is perfect for learning.

Why this changed everything

  • Long context: a token can use information from anywhere in the window, not just its neighbour.
  • Parallel training: every position is computed at once with matrix multiplications, which GPUs are extremely fast at. Older recurrent networks had to go one token at a time.
  • Interpretable-ish: you can print attention weights and see what a token looked at.
⚠️ Attention cost grows with the square of the sequence length (the TΓ—T matrix). Doubling the context window roughly quadruples that part of the work β€” one reason long context is expensive.

Coming next

One head learns one kind of relationship. In Lesson 6 we run several heads side by side (multi-head attention), add a small feed-forward network, residual connections and layer normalisation β€” and that stack is a transformer block. Lesson 7 trains it on your own text.

Leave a Reply

Your email address will not be published. Required fields are marked *