Chapter 3 · Main chapter
Attention from Scratch
Attention is how each token decides which other tokens matter. We build it in four passes — each a small change to the one before — ending at the causal multi-head attention a GPT actually runs.
Adapted from the original notebook by Sebastian Raschka · the Python shown is the book's; the demos run a faithful JavaScript port in your browser
Every act below runs on one tiny example: the six tokens "Your journey starts with one step" as 3-dimensional vectors. Small enough to compute every number in your browser — and the exact numbers the reference Python prints.
Act 1 · Attention is a weighted average
no fixed bottleneck — every token gets to weigh every other token directly
Older models crushed a whole sentence into one fixed vector before decoding — a bottleneck. Self-attention removes it: to build each token's meaning, let it look directly at every token, near or far.
That average had no dials to turn. Learning starts the moment we insert weight matrices between the tokens and their comparison.
Act 2 · Attention that can learn
W_query
W_key
W_value
3 × 2 each · learned (here: torch seed 123)
Act 1 had no parameters. Real attention learns three matrices — W_query, W_key, W_value— that reshape every token before it's compared.
One problem remains for a text generator: this attends to everything — including words that haven't been written yet.
Act 3 · A token can't see the future
every token attends to every token — even the future
Here's the full attention matrix again. But an LLM generates left to right: when it's predicting word 3, it must not peek at words 4, 5, 6. Right now it can — every row is full.
A single masked head reads the sentence one way. Transformers want several, in parallel, without paying several times over.
Act 4 · Many heads, one output
one head · d_out = 2 · one view of the sentence
A single attention head gives one view of the sentence. But one view is limiting — you might want to track syntax and meaning and position at once.
The lab
Drive the attention matrix yourself. Toggle the causal mask, warm or cool the softmax, and click any cell to watch one query meet one key and become a weight.
Attention explorer
Click any cell to trace how one token's query meets another's key and becomes a weight.
rows = queries · columns = keys · each row sums to 1
journey → Your
÷√2 → softmax → weight = 0.2041
raw q·kᵀ from the port = 0.4656
And the scaling trick from Act 2, made tangible — slide the key dimension up and watch the un-scaled softmax collapse into a spike.
Why divide by √dₖ?
Slide the key dimension up and watch the un-scaled distribution collapse to a spike.
unscaled
peak 0.30
scaled by √dₖ
peak 0.20
Illustrative: raw = base × √dₖ emulates dot products of dₖ-dimensional vectors. Un-scaled, softmax saturates toward one-hot as dₖ grows and gradients vanish; dividing by √dₖ keeps the distribution soft.
The real code
The demos run a TypeScript port; here is the Python they mirror. The whole of self-attention is a dozen lines — three projections, scaled dot products, softmax, a weighted sum of values:
class SelfAttention_v2(nn.Module):
def __init__(self, d_in, d_out, qkv_bias=False):
super().__init__()
self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
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) # ÷ √dₖ
return attn_weights @ values # context vectorsCausal masking is two extra lines — set the upper triangle to -inf before the
softmax, so a token can never attend to its future:
mask = torch.triu(torch.ones(context_length, context_length), diagonal=1)
masked = attn_scores.masked_fill(mask.bool(), -torch.inf)
attn_weights = torch.softmax(masked / keys.shape[-1]**0.5, dim=-1)Show the full causal multi-head attention class
class MultiHeadAttention(nn.Module):
def __init__(self, d_in, d_out, context_length, dropout, num_heads, qkv_bias=False):
super().__init__()
assert d_out % num_heads == 0, "d_out must be divisible by num_heads"
self.d_out = d_out
self.num_heads = num_heads
self.head_dim = d_out // num_heads
self.W_query = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_key = nn.Linear(d_in, d_out, bias=qkv_bias)
self.W_value = nn.Linear(d_in, d_out, bias=qkv_bias)
self.out_proj = nn.Linear(d_out, d_out)
self.dropout = nn.Dropout(dropout)
self.register_buffer(
"mask",
torch.triu(torch.ones(context_length, context_length), diagonal=1))
def forward(self, x):
b, num_tokens, d_in = x.shape
keys = self.W_key(x) # (b, num_tokens, d_out)
queries = self.W_query(x)
values = self.W_value(x)
# split d_out into heads → (b, num_tokens, num_heads, head_dim)
keys = keys.view(b, num_tokens, self.num_heads, self.head_dim)
values = values.view(b, num_tokens, self.num_heads, self.head_dim)
queries = queries.view(b, num_tokens, self.num_heads, self.head_dim)
# heads become a batch dim → (b, num_heads, num_tokens, head_dim)
keys, queries, values = keys.transpose(1, 2), queries.transpose(1, 2), values.transpose(1, 2)
attn_scores = queries @ keys.transpose(2, 3) # per-head dot products
mask_bool = self.mask.bool()[:num_tokens, :num_tokens]
attn_scores.masked_fill_(mask_bool, -torch.inf)
attn_weights = torch.softmax(attn_scores / keys.shape[-1]**0.5, dim=-1)
attn_weights = self.dropout(attn_weights)
context_vec = (attn_weights @ values).transpose(1, 2) # (b, num_tokens, num_heads, head_dim)
context_vec = context_vec.contiguous().view(b, num_tokens, self.d_out)
return self.out_proj(context_vec) # combine headsFour things to remember
- Attention is a weighted average. Each token's output is a blend of all tokens' value vectors; the weights are a softmax over query·key scores. Acts 1–2 are that idea, first without and then with learnable weights.
- Query, key, value are three views of the same token. What it looks for, how it's found, and what it passes on — each a learned linear projection.
- Causal masking makes it a language model. Setting the upper triangle to −∞ before softmax (so those weights come out zero) stops a token from seeing the future, which is what lets the model generate left to right.
- Multi-head attention is a split, not a stack. One projection sliced into heads gives several attention patterns for the price of one — the block GPT repeats a dozen times.
Adapted from Build a Large Language Model (From Scratch)
by Sebastian Raschka — original notebook
(Apache 2.0). The attention weights and context vectors on this page are torch's
real seeded outputs, verified number-for-number against the Python with
npm run verify:attention.