Skip to content

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

Yourjourneystartswithonestep
journeylooks at →
Yourjourneystartswithonestep

no fixed bottleneck — every token gets to weigh every other token directly

01 / 05

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

Yourjourneystartswithonestep

W_query

0.300.52
0.250.69
0.070.87

W_key

0.140.10
0.180.73
0.320.69

W_value

0.080.20
0.320.40
0.120.83

3 × 2 each · learned (here: torch seed 123)

01 / 05

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

query ↓·key →
Yourjourneystartswithonestep
Your
.19
.16
.17
.15
.17
.15
journey
.20
.17
.17
.15
.17
.15
starts
.20
.17
.17
.15
.17
.15
with
.19
.17
.17
.16
.17
.16
one
.18
.17
.17
.16
.17
.16
step
.19
.17
.17
.15
.17
.15

every token attends to every token — even the future

01 / 04

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

Yourjourneystartswithonestep
Your
.00
·
·
·
·
·
journey
.48
.52
·
·
·
·
starts
.32
.34
.34
·
·
·
with
.24
.25
.25
.25
·
·
one
.20
.21
.21
.19
.20
·
step
.16
.17
.17
.17
.16
.17

one head · d_out = 2 · one view of the sentence

01 / 04

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.

Yourjourneystartswithonestep
Your
journey
starts
with
one
step

rows = queries · columns = keys · each row sums to 1

journeyYour

q10.91-0.45k00.31-0.40
q·k = 0.4656 (raw score)
÷√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

0.11
s1
0.30
s2
0.27
s3
0.09
s4
0.05
s5
0.18
s6

peak 0.30

scaled by √dₖ

0.16
s1
0.20
s2
0.19
s3
0.15
s4
0.13
s5
0.18
s6

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:

ch03.ipynb · §3.4.2
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 vectors

Causal masking is two extra lines — set the upper triangle to -inf before the softmax, so a token can never attend to its future:

ch03.ipynb · §3.5.1
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
ch03.ipynb · §3.6.2
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 heads

Four things to remember

  1. 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.
  2. 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.
  3. 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.
  4. 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.