TAI BUI
Back to curriculum
BuildPython

Self-Attention from Scratch

Attention is a soft lookup: each token asks "who matters to me?" and learns the answer.

TIME: ~90 minutesPREREQUISITES: Phase 3 (Deep Learning Core), Phase 5 Lesson 10 (Sequence-to-Sequence)

Learning Objectives

  • Implement scaled dot-product self-attention from scratch using only NumPy, including query/key/value projections and the softmax-weighted sum
  • Explain how multi-head attention runs parallel heads, concatenates their outputs, and projects them through Wo, then implement the chunk-splitting version yourself
  • Trace how the attention matrix captures token relationships and explain why scaling by sqrt(d_k) prevents softmax saturation
  • Explain how causal masking turns bidirectional attention into autoregressive (decoder-style) attention, and add it to the attention function yourself

The Problem

Self-attention is a mechanism that lets each position in a sequence attend to every other position in a single parallel step, using weights computed from the data itself.

It exists because RNNs process a sequence one token at a time. By token 50, the information from token 1 has been squeezed through 50 compression steps. Long-range dependencies get crushed into a fixed-size hidden state, a bottleneck that LSTM gating softens but doesn't fully solve.

The 2014 Bahdanau attention paper offered a fix: let the decoder look back at every encoder position and decide which ones matter for the current step. It was still bolted onto an RNN. The 2017 "Attention Is All You Need" paper asked a sharper question: what if attention is the sole mechanism? No recurrence, no convolution, just attention.

That single parallel step is what makes transformers fast, scalable, and dominant.

Warm-up0/3
1. A plain RNN encoder-decoder squeezes a whole input sentence into one fixed-size hidden state. What goes wrong as the sentence gets longer?Warm-up
A row of input tokens, token 1 to token n, each feeding a chain of hidden states h1 to hn, where hn is one fixed-size vector passed to a decoder.
Schematic, not a trained model.
2. In Phase 3 you applied softmax to a layer's raw outputs. What does it produce from a vector of scores?Warm-up
Bars for three raw layer outputs, 2.0, 1.0 and 0.1, going into a box labelled softmax that produces a question mark.
Values are the lesson's Step 1 logits.
3. Compared with two perpendicular vectors, what is the dot product of two vectors that point in nearly the same direction?Warm-up
Two panels. Left: a blue vector (2,0) and an orange vector (0,2) at right angles, dot product marked with a question mark. Right: a blue vector (2,0) and an orange vector (2,0.4) pointing almost the same way, dot product marked with a question mark.
Vectors are made up for the picture.
Check: The Problem0/1
1. Why can self-attention relate token 1 and token 50 without a step-by-step pass over the sequence like an RNN?Explain
Top: a left-to-right chain from token 1 to token 50 read one token at a time. Bottom: token 1 and token 50 joined by a dashed line with a question mark.
Schematic.

The Concept

The Database Lookup Analogy

Self-attention is a function that maps a sequence of n vectors to a sequence of n vectors. Each output is a weighted sum of value vectors, and the weights come from how well that token's query matches every token's key.

The easiest way to hold that in your head is a soft database lookup:

Traditional database:
  Query: "capital of France"  -->  exact match  -->  "Paris"

Attention:
  Query: "capital of France"  -->  similarity to ALL keys  -->  weighted blend of ALL values

Each token produces three vectors:

  • Query (Q): "What am I looking for?"
  • Key (K): "What do I contain?"
  • Value (V): "What information do I provide if selected?"

A query's dot product with every key gives one score per token, and a high score means "this key matches my query." After softmax (covered below), those scores weight the values, and the output is their weighted sum.

Q, K, V Computation

Each token embedding is multiplied by three learned weight matrices:

Input embeddings (sequence of n tokens, each d-dimensional):

  X = [x1, x2, x3, ..., xn]       shape: (n, d)

Three weight matrices:

  Wq  shape: (d, dk)
  Wk  shape: (d, dk)
  Wv  shape: (d, dv)

Projections:

  Q = X @ Wq    shape: (n, dk)      each token's query
  K = X @ Wk    shape: (n, dk)      each token's key
  V = X @ Wv    shape: (n, dv)      each token's value

Visually, for one token:

             Wq
  x_i ------[*]------> q_i    "What am I looking for?"
       |
       |     Wk
       +----[*]------> k_i    "What do I contain?"
       |
       |     Wv
       +----[*]------> v_i    "What do I offer?"

The Attention Matrix

With Q, K, V in hand for all tokens, the attention scores form a matrix:

Scores = Q @ K^T    shape: (n, n)

              k1    k2    k3    k4    k5
        +-----+-----+-----+-----+-----+
   q1   | 2.1 | 0.3 | 0.1 | 0.8 | 0.2 |   <- how much q1 attends to each key
        +-----+-----+-----+-----+-----+
   q2   | 0.4 | 1.9 | 0.7 | 0.1 | 0.3 |
        +-----+-----+-----+-----+-----+
   q3   | 0.2 | 0.6 | 2.3 | 0.5 | 0.1 |
        +-----+-----+-----+-----+-----+
   q4   | 0.9 | 0.1 | 0.4 | 1.7 | 0.6 |
        +-----+-----+-----+-----+-----+
   q5   | 0.1 | 0.3 | 0.2 | 0.5 | 2.0 |
        +-----+-----+-----+-----+-----+

Each row: one token's attention over the entire sequence

Watch one query at a time sweep the keys: each row scores every token, softmax turns the scores into weights, and the context vector is the weighted blend of values.

Why Scale?

Dot products grow with the dimension dk. If dk = 64, they can land in the tens, which pushes softmax into regions where gradients vanish. Dividing by sqrt(dk) fixes it:

Scaled scores = (Q @ K^T) / sqrt(dk)

That keeps the scores in a range where softmax still gives useful gradients.

Softmax Turns Scores into Weights

Softmax converts raw scores into a probability distribution across each row:

Raw scores for q1:   [2.1, 0.3, 0.1, 0.8, 0.2]   (before the 1/sqrt(dk) scaling, to keep the arithmetic simple)
                            |
                         softmax
                            |
Attention weights:   [0.58, 0.10, 0.08, 0.16, 0.09]   (sums to 1.0, up to rounding)

Each token now has a row of weights saying how much to attend to every other token.

Weighted Sum of Values

The final output for each token is a weighted sum of all value vectors:

output_i = sum( attention_weight[i][j] * v_j  for all j )

For token 1:
  output_1 = 0.58 * v1 + 0.10 * v2 + 0.08 * v3 + 0.16 * v4 + 0.09 * v5

Full Pipeline

Mermaid diagram

The whole pipeline in one line:

Attention(Q, K, V) = softmax( Q @ K^T / sqrt(dk) ) @ V

Causal Masking

Causal masking is the change that turns bidirectional self-attention (every token sees every token) into the decoder-style attention used for text generation. Before softmax, every score whose key position j comes after the query position i is set to negative infinity. Since exp(-inf) = 0, those weights come out exactly zero, and row i spreads its weight over positions 0 through i only. A token can no longer peek at the tokens it is supposed to predict. The code in Build It has no mask parameter, so adding one is Exercise 1.

Check: The Concept0/20
1. What are the three projections in self-attention?Define
An input matrix X of shape (n, d) feeding three learned matrices W1, W2 and W3 of shapes (d, dk), (d, dk) and (d, dv), each producing an unnamed output marked with a question mark.
Matrix names are placeholders.
2. What is a query vector in self-attention?Define
A 6 by 8 grid of numbers, one row for each token of The cat sat on the mat, titled Q, with the values from the lesson's Step 4 run.
Real values from running the lesson's code (seed 42, random embeddings, so the numbers carry no meaning).
3. What role does a key vector play in self-attention?Define
A 6 by 8 grid of numbers, one row for each token of The cat sat on the mat, titled K, with the values from the lesson's Step 4 run.
Real values from running the lesson's code (seed 42, random embeddings, so the numbers carry no meaning).
4. What does a token's value vector carry?Define
A 6 by 8 grid of numbers, one row for each token of The cat sat on the mat, titled V, with the values from the lesson's Step 4 run.
Real values from running the lesson's code (seed 42, random embeddings, so the numbers carry no meaning).
5. Which line is the full scaled dot-product attention formula from the Concept section?Define
Three input matrices Q, K and V with shapes (n, dk), (n, dk) and (n, dv) feeding a highlighted box labelled full attention formula, which produces an output of shape (n, dv).
Schematic, formula deliberately not drawn.
6. What makes attention "self"-attention?Define
The six tokens The cat sat on the mat as the source of Q, and two highlighted boxes with question marks for the sources of K and V.
The sentence is the lesson's Step 4 example.
7. What are the attention weights for one token?Define
The lesson's 5 by 5 example matrix with the row for q1 highlighted, and below it five boxes with question marks for the attention weights of q1.
Values copied from the lesson's Attention Matrix section.
8. What is a causal mask?Define
The 6 by 6 attention weight matrix of The cat sat on the mat, with all cells nonzero, and a prompt asking what a causal mask does.
Real weights from the lesson's Step 4 run.
9. In a token's output, what job do the keys do compared with the values?Explain
Three input boxes for the token sat, a query vector q_sat of shape 8, a key matrix K of shape 6 by 8 and a value matrix V of shape 6 by 8, with arrows into a highlighted output_sat box.
Shapes from the lesson's Step 4 sizes.
10. Why is the score matrix divided by sqrt(dk) before softmax?Explain
A pipeline of four boxes, Q @ K^T, divide by sqrt(dk) with dk equal to 8, softmax, and @ V, with the divide box highlighted.
Schematic with the lesson's Step 4 sizes.
11. Why does the causal mask use negative infinity for blocked positions rather than a small score?Explain
One row of six scaled scores for the token sat, with the three columns for later positions greyed, and six boxes with question marks below for the resulting weights.
Real scaled scores from the lesson's Step 4 run.
12. Why does self-attention cost O(n^2) in sequence length n?Explain
A row of 6 token boxes labelled n equals 6 and a row of 12 token boxes labelled n equals 12, with a prompt asking how the attention cost changes when n doubles.
Schematic; token boxes are placeholders.
13. For raw scores [2.1, 0.3, 0.1, 0.8, 0.2], the softmax example gives the first token about 0.58 and the fourth about 0.16. Why is that gap so large when 2.1 is only about 2.6 times 0.8?Apply
Bars for the five raw scores 2.1, 0.3, 0.1, 0.8 and 0.2, with question marks for the weights of the first and fourth tokens.
Scores copied from the lesson's softmax example.
14. A causal mask is added to the 6-token sentence "The cat sat on the mat". Which positions get nonzero weight in the row for "sat"?Apply
A 6 by 6 grid with rows for The cat sat on the mat and columns numbered 0 to 5, empty except for the sat row, whose six cells hold question marks.
Positions count from 0.
15. You delete the division by sqrt(dk) and raise dk from 8 to 512. Each attention row now puts almost all its weight on one token. What explains this?Analyze
Two 6 by 6 grids of attention weights. Left: dk 8 with the division, weights spread across each row. Right: dk 512 with no division, each row putting nearly all its weight on one cell.
Computed by the script with the lesson's SelfAttention (left: the Step 4 run, d_model 16 and dk 8; right: d_model = dk = 512), random embeddings.
16. A teammate's softmax normalizes down the columns of the (n, n) score matrix instead of along each row. What goes wrong?Analyze
The lesson's 5 by 5 score matrix with rows q1 to q5 and columns k1 to k5, the column for k2 outlined, and a note that the teammate's softmax runs down columns.
Values copied from the lesson's Attention Matrix section.
17. A teammate masks the future by setting those scores to 0 instead of -inf before softmax. What happens?Analyze
One row of six scores for the token sat in which the last three cells, the later positions, have been replaced with 0, and six boxes with question marks below for the weights after softmax.
Real scaled scores from the lesson's Step 4 run; later positions replaced by the script.
18. A decoder is trained to predict the next token. Position 3 can see the real token at position 5, and the loss drops suspiciously fast. What is missing?Analyze
Six boxes for positions 1 to 6 in a row, with a red curved arrow from position 5 to position 3 labelled pos 3 can read pos 5, and notes about next-token training and a fast-dropping loss.
Positions are the stem's; schematic.
19. You want to reuse `scaled_dot_product_attention` in a text generator that predicts each token from the earlier ones. What is the minimum change?Design
The six-line scaled_dot_product_attention function from the lesson's Build It Step 2, with a note that the goal is to predict each token from earlier ones only.
Code copied from the lesson.
20. You scale d_model and dk from 16 and 8 up to 512 and want training gradients to stay healthy. What should the attention function do?Design
A table comparing the Step 4 model with the target: d_model 16 versus 512, dk 8 versus 512, 6 tokens in both, with a prompt asking what the attention function should do for healthy gradients.
Sizes from the stem.

Build It

Step 1: Softmax from scratch

Softmax converts raw logits into probabilities. Subtracting the max first keeps the exponentials from overflowing.

python
import numpy as np
 
def softmax(x):
    shifted = x - np.max(x, axis=-1, keepdims=True)
    exp_x = np.exp(shifted)
    return exp_x / np.sum(exp_x, axis=-1, keepdims=True)
 
logits = np.array([2.0, 1.0, 0.1])
print(f"logits:  {logits}")
print(f"softmax: {softmax(logits)}")
print(f"sum:     {softmax(logits).sum():.4f}")

The full self_attention.py also runs softmax on [100, 200, 300]. Because of the max shift it returns a finite result, roughly [0, 0, 1], and the sum is still 1.

Step 2: Scaled dot-product attention

The core function. It takes Q, K, V and returns the attention output plus the weight matrix.

python
def scaled_dot_product_attention(Q, K, V):
    dk = Q.shape[-1]
    scores = Q @ K.T / np.sqrt(dk)
    weights = softmax(scores)
    output = weights @ V
    return output, weights

Step 3: Self-attention class with learned projections

A self-attention module that holds Wq, Wk, Wv, initialized with Xavier-like scaling. The Q/K scale is named scale_qk so it doesn't collide with scale_v, and forward returns the (output, weights) tuple straight from scaled_dot_product_attention.

python
class SelfAttention:
    def __init__(self, d_model, dk, dv, seed=42):
        rng = np.random.default_rng(seed)
        scale_qk = np.sqrt(2.0 / (d_model + dk))
        self.Wq = rng.normal(0, scale_qk, (d_model, dk))
        self.Wk = rng.normal(0, scale_qk, (d_model, dk))
        scale_v = np.sqrt(2.0 / (d_model + dv))
        self.Wv = rng.normal(0, scale_v, (d_model, dv))
        self.dk = dk
 
    def forward(self, X):
        Q = X @ self.Wq
        K = X @ self.Wk
        V = X @ self.Wv
        return scaled_dot_product_attention(Q, K, V)

Step 4: Run it on a sentence

Make random embeddings for a sentence and print the attention weights.

python
sentence = ["The", "cat", "sat", "on", "the", "mat"]
n_tokens = len(sentence)
d_model = 16
dk = 8
dv = 8
 
rng = np.random.default_rng(42)
X = rng.normal(0, 1, (n_tokens, d_model))
 
attn = SelfAttention(d_model, dk, dv, seed=42)
output, weights = attn.forward(X)
 
print("Attention weights (each row: where that token looks):\n")
print(f"{'':>6}", end="")
for token in sentence:
    print(f"{token:>6}", end="")
print()
 
for i, token in enumerate(sentence):
    print(f"{token:>6}", end="")
    for j in range(n_tokens):
        w = weights[i][j]
        print(f"{w:6.3f}", end="")
    print()

Step 5: Visualize attention with ASCII heatmap

Map each weight to a shade character for a quick look at the matrix.

python
def ascii_heatmap(weights, tokens, chars=" ░▒▓█"):
    n = len(tokens)
    print(f"\n{'':>6}", end="")
    for t in tokens:
        print(f"{t:>6}", end="")
    print()
 
    for i in range(n):
        print(f"{tokens[i]:>6}", end="")
        for j in range(n):
            level = int(weights[i][j] * (len(chars) - 1) / weights.max())
            level = min(level, len(chars) - 1)
            print(f"{'  ' + chars[level] + '   '}", end="")
        print()
 
ascii_heatmap(weights, sentence)

Step 6: Multi-head attention

One attention function can only score tokens one way. The file's MultiHeadSelfAttention runs several in parallel. Each head is a SelfAttention with its own seed and dk = dv = d_model // n_heads, the head outputs are concatenated, and a final matrix Wo projects the result back to d_model.

python
class MultiHeadSelfAttention:
    def __init__(self, d_model, n_heads, seed=42):
        assert d_model % n_heads == 0
        self.n_heads = n_heads
        self.dk = d_model // n_heads
        self.dv = d_model // n_heads
        self.heads = [
            SelfAttention(d_model, self.dk, self.dv, seed=seed + i)
            for i in range(n_heads)
        ]
        rng = np.random.default_rng(seed + n_heads)
        scale = np.sqrt(2.0 / (d_model + d_model))
        self.Wo = rng.normal(0, scale, (n_heads * self.dv, d_model))
 
    def forward(self, X):
        head_outputs = []
        all_weights = []
        for head in self.heads:
            out, w = head.forward(X)
            head_outputs.append(out)
            all_weights.append(w)
        concatenated = np.concatenate(head_outputs, axis=-1)
        output = concatenated @ self.Wo
        return output, all_weights

Run python self_attention.py to see the single-head matrix, the heatmap, and both heads' weights for the same sentence. Two things are left to you: the file has no mask parameter (causal masking is Exercise 1), and it gives each head its own projections rather than splitting one Q, K, V into chunks (Exercise 2).

Check: Build It0/12
1. What is multi-head attention?Define
Two 6 by 6 heat grids of attention weights, Head 1 and Head 2, with different patterns, feeding an arrow to a box labelled combine with a question mark.
Real weights from the lesson's self_attention.py (2 heads, random embeddings).
2. In the Build It softmax, why is `np.max(x, axis=-1, keepdims=True)` subtracted before exponentiating?Explain
The four-line softmax function from the lesson's Build It Step 1, with the line that subtracts np.max highlighted.
Code copied from the lesson.
3. Why does multi-head attention end with a matrix Wo after concatenating the heads?Explain
Two head outputs of shape 6 by 8 joined by a concat box, then a highlighted Wo box with unknown shape, then an output of shape 6 by a question mark.
Schematic with the lesson's 2-head, d_model 16 sizes.
4. In the Build It code, X has shape (6, 16) and dk = dv = 8. What are the shapes of the scores matrix and the output?Apply
An input matrix X of shape 6 by 16 with dk and dv equal to 8, leading to a scores box and an output box whose shapes are marked with question marks.
Sizes from the lesson's Step 4.
5. Step 4 uses 6 tokens with d_model = 16, dk = 8. You rerun it on a 10-token sentence with the same sizes. What are the shapes of Wq and the weights?Apply
A table with columns for the Step 4 run and the rerun: 6 versus 10 tokens, X of shape 6 by 16 versus 10 by 16, Wq of shape 16 by 8 versus a question mark, weights of shape 6 by 6 versus a question mark.
Step 4 column from running the lesson's code.
6. The Build It softmax is run on [100, 200, 300] after the max shift. What does it return?Apply
Three lines of Python that call the lesson's softmax on the array 100, 200, 300 and print the result and its sum, with the output marked by a question mark.
Code is typed from the stem.
7. In multi-head attention with 8 heads and d_model=512, what is dk for each head?Apply
A token vector of size 512 with arrows to eight head boxes, each labelled dk equal to a question mark.
Schematic.
8. MultiHeadSelfAttention is built with d_model = 16 and n_heads = 4. What are dk and the shape of Wo?Apply
Python lines that build MultiHeadSelfAttention with d_model 16 and n_heads 4, then read m.dk and m.Wo.shape, each marked with a question mark.
Code typed from the stem.
9. In `self_attention.py`, how does `MultiHeadSelfAttention` turn the per-head outputs into one output?Apply
Two head outputs of shape 6 by 8 going into a highlighted box labelled combine with a question mark, which leads to the layer output.
Schematic with the lesson's 2-head sizes.
10. Every head in your MultiHeadSelfAttention produces weights identical to every other head's, on every input. What is the most likely cause?Analyze
Two 6 by 6 heat grids, Head 1 and Head 2 weights, that look exactly the same.
The heads here were set up by the script to reproduce the symptom in the stem; the weights are real numbers from the lesson's SelfAttention class.
11. You feed two different sentences of the same length through one SelfAttention instance. What do you expect to compare?Analyze
Two 6 by 16 inputs, sentence A and sentence B, going into a single SelfAttention instance, with two outputs, Wq Wk Wv and weights, each marked A versus B with a question mark.
Schematic.
12. With d_model = 512 you want several relationship types attended at once, with output size matching the model width. Which design fits?Design
An input of shape n by 512 going into a highlighted box labelled attention design with a question mark, and an output of shape n by 512, with a note about attending several relationship types at once.
Requirements from the stem.

Use It

PyTorch's nn.MultiheadAttention runs the same computation we built, with multi-head splitting and an output projection added:

python
import torch
import torch.nn as nn
 
d_model = 8
n_heads = 2
seq_len = 6
 
mha = nn.MultiheadAttention(embed_dim=d_model, num_heads=n_heads, batch_first=True)
 
X_torch = torch.randn(1, seq_len, d_model)
 
output, attn_weights = mha(X_torch, X_torch, X_torch)
 
print(f"Input shape:            {X_torch.shape}")
print(f"Output shape:           {output.shape}")
print(f"Attention weight shape: {attn_weights.shape}")
print(f"\nAttn weights (averaged over heads):")
print(attn_weights[0].detach().numpy().round(3))

The difference is the multi-head part. It runs several attention functions in parallel, each with its own Q, K, V projections of size dk = d_model / n_heads, then concatenates the results. That lets the model attend to different relationship types at once.

Check: Use It0/1
1. What does running several heads give that a single attention function cannot?Explain
A 6 by 6 heat grid of attention weights from one head, labelled one weight matrix, next to a dashed panel asking what several heads add.
Real weights from the lesson's self_attention.py (head 1, random embeddings).

Ship It

What you ship:

  • outputs/prompt-attention-explainer.md: a prompt for explaining attention through the database lookup analogy
Author's Note

Run the scaling argument yourself before taking it on faith. Delete the / np.sqrt(dk) from scaled_dot_product_attention, then raise d_model and dk together (try 512 for both, up from 16 and 8 in Step 4) and compare the weight rows. Without the division, each row collapses toward one large weight, which is the saturated regime where gradients vanish. With it, the rows stay spread out.

My bet is that the shapes trip people up more than the math does. The attention matrix is (n, n), while Wq, Wk, Wv are (d, dk), (d, dk), (d, dv) and don't depend on n. The first time through, I'd print .shape on Q, K, V, the scores, and the weights at each step.

Once single-head attention makes sense, the multi-head class in self_attention.py is short: each head is a SelfAttention with its own seed and size d_model / n_heads, the head outputs are concatenated, and Wo projects the result. Exercise 2 asks for the same idea by splitting Q, K, V into chunks instead of giving each head its own projections, and I'd attempt it before opening the file.

Exercises

  1. Modify scaled_dot_product_attention to accept an optional mask matrix that sets certain positions to negative infinity before softmax (this is how causal/decoder masking works). self_attention.py has no mask parameter, so this one is entirely yours: build a lower-triangular mask and check that every weight above the diagonal comes out zero
  2. Implement multi-head attention from scratch: split Q, K, V into n_heads chunks, run attention on each, concatenate, and project through a final weight matrix Wo
  3. Take two different sentences of the same length, feed them through the same SelfAttention instance, and compare their attention patterns. What changes? What stays the same?

Key Terms

TermWhat people sayWhat it actually means
Query (Q)"The question vector"A learned projection of the input that represents what information this token is looking for
Key (K)"The label vector"A learned projection that represents what information this token contains, matched against queries
Value (V)"The content vector"A learned projection carrying the actual information that gets aggregated based on attention scores
Scaled dot-product attention"The attention formula"softmax(QK^T / sqrt(dk)) @ V. The scaling keeps softmax from saturating in high dimensions
Self-attention"The token looks at itself and others"Attention where Q, K, V all come from the same sequence, letting every position attend to every other position
Attention weights"How much focus"A probability distribution over positions, produced by softmax over scaled dot products
Multi-head attention"Parallel attention"Running multiple attention functions with different projections, then concatenating results for richer representations
Causal mask"Hiding the future"Setting scores for later positions to negative infinity before softmax, so each token attends only to itself and earlier tokens

Further Reading

Code

Ships With