LLM Developer DocsLLM Docs

Loading the search index ...

↑ ↓ move, Enter open, Esc close

GitHub

Attention

Multi-head causal self-attention as transformer.py implements it, with a step-through lab, every equation tied to its lines, the tensor shapes at the real config, and the hand-written backward pass.

transformer.py

Attention is the part of a block where positions exchange information. This page goes through attention_forward line by line, then attention_backward. Every equation links to the code that computes it, and every Illustrative Example is computed from that code's mirror.

Why attention?

A linear layer treats each row on its own: row t of its output depends only on row t of its input. A model that predicts the next token also needs earlier tokens to affect the current one. Attention does this by building each output row from a weighted mix of rows at that position and earlier ones. The weights are computed from the input, so they differ from one sequence to the next.

In general, these three names are explained with a question for each one:

  • Query: what am I looking for?
  • Key: what do I contain?
  • Value: what do I hand over?

Those questions are a way to remember the roles. They are not something this project computes. In the code, a query, a key and a value are three linear projections of one input. A query times a key gives a score, and the scores decide how the values are mixed.

The example on this page. The sentence is "The cat sat on the mat", with one row per word and hand-picked numbers: T = 6, d = 4 and H = 2, so d_h = 2. Every number in this example is labelled Illustrative Example. The real model works differently: it reads sub-word tokens, not words, and it has H = 2 heads of width d_h = 16, inside rows of width d = 32. Nothing written on this page says what the trained model attends to. The only real numbers are the exported weights of one window, under Actual Model Output, when an export exists.

Attention lab

Pick a token and a head, then step through the computation. The code under the matrix marks the lines of attention_forward for the current step. Each step links to its section below.

Data source

Illustrative Example shows hand-picked numbers. Actual Model Output shows exported weights from one real window. The two are never shown together.

Token
Head
Step 1 of 5: Scores

Cell (i, j) is the dot product of row i's query with row j's key. Python computes this and the next step on one line. Read this step

Scores, head 0Illustrative ExampleHand-picked input. These are not learned values, and they say nothing about the trained model.
reads →row ↓ThecatsatonthematThecatsatonthematThe, The: -0.99-0.99The, cat: -0.83-0.83The, sat: 0.460.46The, on: 0.290.29The, the: -0.36-0.36The, mat: -0.08-0.08cat, The: 1.351.35cat, cat: -1.10-1.10cat, sat: -0.23-0.23cat, on: -1.75-1.75cat, the: 1.151.15cat, mat: -1.42-1.42sat, The: -0.48-0.48sat, cat: 0.830.83sat, sat: 0.000.00sat, on: 0.890.89sat, the: -0.54-0.54sat, mat: 0.800.80on, The: 1.411.41on, cat: 0.660.66on, sat: -0.56-0.56on, on: -0.73-0.73on, the: 0.670.67on, mat: -0.26-0.26the, The: -0.85-0.85the, cat: 0.150.15the, sat: 0.240.24the, on: 0.770.77the, the: -0.57-0.57the, mat: 0.520.52mat, The: 0.710.71mat, cat: 0.890.89mat, sat: -0.38-0.38mat, on: -0.03-0.03mat, the: 0.170.17mat, mat: 0.250.25

Low -1.75High 1.41

Data table
row \ readsThecatsatonthemat
The-0.99-0.830.460.29-0.36-0.08
cat1.35-1.10-0.23-1.751.15-1.42
sat-0.480.830.000.89-0.540.80
on1.410.66-0.56-0.730.67-0.26
the-0.850.150.240.77-0.570.52
mat0.710.89-0.38-0.030.170.25

Weights for the selected token appear at Softmax.

transformer.pyattention_forwardGitHub: transformer.py
def attention_forward(Xn, params, p, cfg: ModelConfig):    T, d = Xn.shape    H = cfg.num_heads    dh = d // H # width of one head    Q = Xn @ params[p+"wq"] + params[p+"bq"]    # (T,d), QUERY    K = Xn @ params[p+"wk"] + params[p+"bk"]    # KEY    V = Xn @ params[p+"wv"] + params[p+"bv"]    # VALUE    Qh, Kh, Vh = split_heads(Q, H), split_heads(K, H), split_heads(V, H)    S = (Qh @ Kh.transpose(0, 2, 1)) / np.sqrt(dh)    S = np.where(np.triu(np.ones((T, T), dtype=bool), 1), -np.inf, S)    A = softmax_forward(S)    O = merge_heads(A @ Vh)    out = O @ params[p + "wo"] + params[p + "bo"]    return out, dict(Xn=Xn, Qh=Qh, Kh=Kh, Vh=Vh, A=A, O=O)

Actual Model Output

Actual Model OutputOne real window

Commit 66da45b. Checkpoint model.npz sha256 74de2ec88a63. Seed 42, from config.py, since a checkpoint does not record its own. Read from checkpoints/model, an ignored local file, trained on data/corpus.csv (git-ignored, and not at the pinned commit), with training settings that by inference differ from config.py.

In the lab above, choose Actual Model Output to see the weights A for every layer and head. They come from running forward on one window of 16 real tokens: ␠back␠to␠the␠game.\n'You␠can'

Checks on all 4 matrices, each 16 by 16: the shapes are right, every row sums to 1 (largest gap 3.3e-16), and every masked cell is exactly 0. Checkpoint weights file: 74de2ec88a63.

Where these numbers came from
Repository commit66da45bea72176fa5d2f8dba2ea1c7e597e30dd3
model.npz sha25674de2ec88a63ceb131a27e03cc63070c6d8c4174cc78bc189195e9356e84550e
model.json sha256f93002949162068d573aab9b38990b12aec7f32b468ec9eaaef986075df7d3b4
model.tok.json sha256bf874ab4e4de2b61cb936f38635a7c589c5c0d29879947a5b45d13be2456b17d
Corpus data/corpus.csv sha256c1bec80f274294e6ce989fcc63fd7022a206f415e7d3df85e82c410e56dc4f4f
Tokenizer rebuilt from this corpus equals the checkpoint'sYes
Model shape, from the checkpoint{"context_len":32,"emb_dim":32,"ff_dim":128,"ln_eps":0.00001,"num_heads":2,"num_layers":2,"vocab_size":575}
config.py, when this was exported{"adam_eps":1e-8,"batch_size":16,"beta1":0.9,"beta2":0.999,"context_len":32,"corpus_column":"text","corpus_path":"data/corpus.csv","emb_dim":32,"epochs":20,"ff_dim":128,"grad_clip":1,"head_dim":16,"ln_eps":0.00001,"lr_max":0.001,"lr_min":0.0001,"max_new_tokens":50,"max_rows":2000,"num_heads":2,"num_layers":2,"num_merges":500,"seed":42,"seq_len":16,"stride":1,"temperature":0.8,"top_k":0,"top_p":0.9,"val_fraction":0.1,"warmup_steps":50,"weight_decay":0.01}
Seed42
EnvironmentPython 3.14.4, NumPy 2.5.2, pandas 3.1.0

Q, K, V projections

What is it?

Q, K, V projectionsImplemented

Attention

Attention makes three new matrices from its input Xn: the query Q, the key K and the value V. Each is a linear layer with its own weight matrix and bias. All three have the same shape as Xn.

Why does the model need it?

The later steps need three different views of each row. Scores compare queries with keys, and the output is built from values. Separate weights let the three views differ.

Mathematics

Query projection #Link to equation: Query projection
Q=XnWq+1bq⊤Q = X_n W_q + \mathbf{1} b_q^\top
Key projection #Link to equation: Key projection
K=XnWk+1bk⊤K = X_n W_k + \mathbf{1} b_k^\top
Value projection #Link to equation: Value projection
V=XnWv+1bv⊤V = X_n W_v + \mathbf{1} b_v^\top

What does each symbol mean?

SymbolMeaningIn codeShape
XnX_nThe input, a layer-normalised copy of the block's inputXn(T, d)
Wq,Wk,WvW_q, W_k, W_vWeight matrices, one per projectionparams[p+"wq"], params[p+"wk"], params[p+"wv"](d, d)
bq,bk,bvb_q, b_k, b_vBiases, added to every rowparams[p+"bq"], params[p+"bk"], params[p+"bv"](d,)
Q,K,VQ, K, VQuery, key and value matrices. On this page V is always the value matrixQ, K, V(T, d)

Tiny numerical example

Three projections of one inputIllustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values.
Xn, shape (6, 4)
0.5-11.50.25
10-0.52
-1.50.750.5-0.25
0.251.25-10.5
0.75-0.51-1
-0.51.50.250.75
Q, shape (6, 4)
-0.815-0.4951.555-0.46
0.56-1.220.680.94
-0.090.805-0.445-0.585
1.0350.255-0.670.74
-0.490.3050.83-0.385
0.660.605-0.070.09
K, shape (6, 4)
1.47-0.43-0.165-0.465
0.371.07-1.291.16
-0.53-0.0551.16-0.19
-0.9550.995-0.3650.81
0.795-0.580.01-0.815
-0.480.9450.2850.685
V, shape (6, 4)
-1.0050.7150.365-0.395
0.971.09-0.21-0.17
-0.155-0.8850.141.005
1.195-0.035-0.0350.555
-0.9050.3150.49-0.395
0.67-0.260.3651.205

Tensor shapes

One projection. Q, K and V all have this shapetransformer.py:75
  1. Xn(T, d)(16, 32)
  2. wq(d, d)(32, 32)
  3. Q(T, d)(16, 32)

T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. d = emb_dim = 32.

Actual implementation

transformer.pyattention_forwardGitHub: transformer.py
    Q = Xn @ params[p+"wq"] + params[p+"bq"]    # (T,d), QUERY    K = Xn @ params[p+"wk"] + params[p+"bk"]    # KEY    V = Xn @ params[p+"wv"] + params[p+"bv"]    # VALUE

Inside this function V is the value matrix, not the vocabulary size. This page uses V only for the value matrix.

init_params creates these parameters, one loop per block, for q, k, v and o:

transformer.pyinit_paramsGitHub: transformer.py
        for n in "qkvo":            p[b + "w" + n] = xavier_uniform(d, d, rng)            p[b + "b" + n] = np.zeros(d)

Code and math connection

EquationRoleLinesCode
Query projectionComputes ittransformer.py:75Q = Xn @ params[p+"wq"] + params[p+"bq"] # (T,d), QUERY
Key projectionComputes ittransformer.py:76K = Xn @ params[p+"wk"] + params[p+"bk"] # KEY
Value projectionComputes ittransformer.py:77V = Xn @ params[p+"wv"] + params[p+"bv"] # VALUE

Where is it used?

Source

transformer.py, lines 70-86, attention_forward

PDF reference

Section 4.5, Eq. 41, p. 10

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Query projectionComputes ittransformer.pyattention_forward75GitHub: transformer.py, line 75
Key projectionComputes ittransformer.pyattention_forward76GitHub: transformer.py, line 76
Value projectionComputes ittransformer.pyattention_forward77GitHub: transformer.py, line 77

Splitting into heads

What is it?

Splitting into headsImplemented

Attention

split_heads cuts the columns of Q, K and V into H equal blocks, one block per head. It reshapes (T, d) to (T, H, d_h), then swaps the first two axes to get (H, T, d_h).

Why does the model need it?

Each head computes its own scores and weights from its own block of columns. NumPy's @ works on the last two axes, so with the head on the first axis, one @ runs every head at once.

Mathematics

Split each matrix into heads #Link to equation: Split each matrix into heads
Qh, Kh, Vh=split⁡(Q,H), split⁡(K,H), split⁡(V,H)Q_h,\ K_h,\ V_h = \operatorname{split}(Q, H),\ \operatorname{split}(K, H),\ \operatorname{split}(V, H)
Width of one head #Link to equation: Width of one head
dh=dH,d mod H=0d_h = \frac{d}{H}, \qquad d \bmod H = 0
Head h owns one block of columns #Link to equation: Head h owns one block of columns
Mt,i(h)=Mt,  hdh+iM^{(h)}_{t,i} = M_{t,\; h d_h + i}

What does each symbol mean?

SymbolMeaningIn codeShape
HHNumber of headsHscalar
dhd_hWidth of one headdhscalar
Qh,Kh,VhQ_h, K_h, V_hThe split matrices. One head of each has the shape (T, d_h)Qh, Kh, Vh(H, T, d_h)

Tiny numerical example

Q, cut into two headsIllustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values. Head 0 holds the first two columns of Q, and head 1 the last two.
Q, shape (6, 4)
-0.815-0.4951.555-0.46
0.56-1.220.680.94
-0.090.805-0.445-0.585
1.0350.255-0.670.74
-0.490.3050.83-0.385
0.660.605-0.070.09
Qh[0], shape (6, 2)
-0.815-0.495
0.56-1.22
-0.090.805
1.0350.255
-0.490.305
0.660.605
Qh[1], shape (6, 2)
1.555-0.46
0.680.94
-0.445-0.585
-0.670.74
0.83-0.385
-0.070.09

Tensor shapes

The reshape, then the transpose, in split_headstransformer.py:62
  1. Q(T, d)(16, 32)
  2. reshape(T, H, dh)(16, 2, 16)
  3. transpose(H, T, dh)(2, 16, 16)

T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. d = emb_dim = 32. H = num_heads = 2. dh = head_dim = 16.

Actual implementation

transformer.pysplit_headsGitHub: transformer.py
def split_heads(M, H):    T, d = M.shape    return M.reshape(T, H, d // H).transpose(1, 0, 2)

The function that calls it computes the width first (transformer.py:73), then splits all three matrices:

transformer.pyattention_forwardGitHub: transformer.py
    Qh, Kh, Vh = split_heads(Q, H), split_heads(K, H), split_heads(V, H)

Code and math connection

EquationRoleLinesCode
Split each matrix into headsCalls ittransformer.py:79Qh, Kh, Vh = split_heads(Q, H), split_heads(K, H), split_heads(V, H)
Split each matrix into headsComputes ittransformer.py:62return M.reshape(T, H, d // H).transpose(1, 0, 2)
Split each matrix into headsTests ittests/test_transformer.py:62assert Mh.shape == (2, 3, 4)

Where is it used?

Source

transformer.py, lines 60-62, split_heads

PDF reference

Section 4.5, p. 10

Used by

Called by: transformer.attention_backward, transformer.attention_forward

Tested by: tests.test_transformer.test_split_merge_heads_round_trip

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Split each matrix into headsComputes ittransformer.pysplit_heads62GitHub: transformer.py, line 62

Scores

What is it?

ScoresImplemented

Attention

In each head, the product of Q_h and the transpose of K_h gives one number for every pair of positions. Entry (i, j) is the dot product of row i's query with row j's key.

Why does the model need it?

The weights have to come from somewhere. A score is the raw number that decides how much of position j's value row i receives, once the next steps have scaled, masked and normalised it.

Mathematics

Scores, one matrix per head #Link to equation: Scores, one matrix per head
QhKh⊤,(QhKh⊤)ij=∑c(Qh)ic(Kh)jcQ_h K_h^\top, \qquad (Q_h K_h^\top)_{ij} = \sum_{c} (Q_h)_{ic} (K_h)_{jc}

What does each symbol mean?

SymbolMeaningIn codeShape
QhQ_hThe queries of one headQh(T, d_h) per head
Kh⊤K_h^\topThe keys of one head, transposed. The code swaps the last two axesKh.transpose(0, 2, 1)(d_h, T) per head
i,ji, jRow and column of the score matrix. i is the position reading, j the position readnot named in the codescalar
ccColumn index inside a headnot named in the codescalar

Tiny numerical example

Qh @ Kh^T, head 0Illustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values.
reads →row ↓ThecatsatonthematThecatsatonthematThe, The: -0.99-0.99The, cat: -0.83-0.83The, sat: 0.460.46The, on: 0.290.29The, the: -0.36-0.36The, mat: -0.08-0.08cat, The: 1.351.35cat, cat: -1.10-1.10cat, sat: -0.23-0.23cat, on: -1.75-1.75cat, the: 1.151.15cat, mat: -1.42-1.42sat, The: -0.48-0.48sat, cat: 0.830.83sat, sat: 0.000.00sat, on: 0.890.89sat, the: -0.54-0.54sat, mat: 0.800.80on, The: 1.411.41on, cat: 0.660.66on, sat: -0.56-0.56on, on: -0.73-0.73on, the: 0.670.67on, mat: -0.26-0.26the, The: -0.85-0.85the, cat: 0.150.15the, sat: 0.240.24the, on: 0.770.77the, the: -0.57-0.57the, mat: 0.520.52mat, The: 0.710.71mat, cat: 0.890.89mat, sat: -0.38-0.38mat, on: -0.03-0.03mat, the: 0.170.17mat, mat: 0.250.25

Low -1.75High 1.41

Data table
row \ readsThecatsatonthemat
The-0.99-0.830.460.29-0.36-0.08
cat1.35-1.10-0.23-1.751.15-1.42
sat-0.480.830.000.89-0.540.80
on1.410.66-0.56-0.730.67-0.26
the-0.850.150.240.77-0.570.52
mat0.710.89-0.38-0.030.170.25

One cell, row 2 and column 1: [-0.09, 0.805] . [0.37, 1.07] = -0.09 * 0.37 + 0.805 * 1.07 = 0.828

Tensor shapes

Scores, before scaling. Both this step and the next are on one line of codetransformer.py:81
  1. Qh(H, T, dh)(2, 16, 16)
  2. Kh.transpose(0, 2, 1)(H, dh, T)(2, 16, 16)
  3. Qh @ Kh^T(H, T, T)(2, 16, 16)

H = num_heads = 2. T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. dh = head_dim = 16.

Actual implementation

The product is the part of this line in brackets. Python never stores it on its own.

transformer.pyattention_forwardGitHub: transformer.py
    S = (Qh @ Kh.transpose(0, 2, 1)) / np.sqrt(dh)

Code and math connection

EquationRoleLinesCode
Scores, one matrix per headComputes ittransformer.py:81S = (Qh @ Kh.transpose(0, 2, 1)) / np.sqrt(dh)

Where is it used?

Source

transformer.py, lines 70-86, attention_forward

PDF reference

Section 4.5, Eq. 42, p. 10

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Scores, one matrix per headComputes ittransformer.pyattention_forward81GitHub: transformer.py, line 81

Scaling by sqrt(d_h)

What is it?

Scaling by sqrt(d_h)Implemented

Attention

The code divides every score by the square root of d_h, on the same line as the product. S is the scaled result.

Why does the model need it?

In general, a dot product of d_h terms grows as d_h grows, and softmax turns large gaps between scores into weights close to 0 or 1. Dividing by sqrt(d_h) keeps the size of the scores from growing with the head width. This is a general explanation. Nothing in this repository measures that effect.

Mathematics

Scaled scores #Link to equation: Scaled scores
S=QhKh⊤dhS = \frac{Q_h K_h^\top}{\sqrt{d_h}}

What does each symbol mean?

SymbolMeaningIn codeShape
SSScaled scores. The next line of code overwrites S with its masked versionS(H, T, T)
dh\sqrt{d_h}The scale. A single number, the same for every entrynp.sqrt(dh)scalar

Tiny numerical example

S, head 0, after scalingIllustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values.
reads →row ↓ThecatsatonthematThecatsatonthematThe, The: -0.70-0.70The, cat: -0.59-0.59The, sat: 0.320.32The, on: 0.200.20The, the: -0.26-0.26The, mat: -0.05-0.05cat, The: 0.950.95cat, cat: -0.78-0.78cat, sat: -0.16-0.16cat, on: -1.24-1.24cat, the: 0.820.82cat, mat: -1.01-1.01sat, The: -0.34-0.34sat, cat: 0.590.59sat, sat: 0.000.00sat, on: 0.630.63sat, the: -0.38-0.38sat, mat: 0.570.57on, The: 1.001.00on, cat: 0.460.46on, sat: -0.40-0.40on, on: -0.52-0.52on, the: 0.480.48on, mat: -0.18-0.18the, The: -0.60-0.60the, cat: 0.100.10the, sat: 0.170.17the, on: 0.550.55the, the: -0.40-0.40the, mat: 0.370.37mat, The: 0.500.50mat, cat: 0.630.63mat, sat: -0.27-0.27mat, on: -0.02-0.02mat, the: 0.120.12mat, mat: 0.180.18

Low -1.24High 1.00

Data table
row \ readsThecatsatonthemat
The-0.70-0.590.320.20-0.26-0.05
cat0.95-0.78-0.16-1.240.82-1.01
sat-0.340.590.000.63-0.380.57
on1.000.46-0.40-0.520.48-0.18
the-0.600.100.170.55-0.400.37
mat0.500.63-0.27-0.020.120.18

This example has d_h = 2, so the scale is sqrt(2) = 1.4142. One cell: 0.828 / 1.4142 = 0.5855. The model has d_h = 16, so its scale is sqrt(16) = 4.

Tensor shapes

Scaling divides by one number, so the shape does not changetransformer.py:81
  1. Qh @ Kh^T(H, T, T)(2, 16, 16)
  2. S(H, T, T)(2, 16, 16)

H = num_heads = 2. T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt.

Actual implementation

The same line as the scores. The division happens before the mask, which is on the next line.

transformer.pyattention_forwardGitHub: transformer.py
    S = (Qh @ Kh.transpose(0, 2, 1)) / np.sqrt(dh)

Code and math connection

EquationRoleLinesCode
Scaled scoresComputes ittransformer.py:81S = (Qh @ Kh.transpose(0, 2, 1)) / np.sqrt(dh)

Where is it used?

Source

transformer.py, lines 70-86, attention_forward

PDF reference

Section 4.5, Eq. 42, p. 10

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Scaled scoresComputes ittransformer.pyattention_forward81GitHub: transformer.py, line 81

Causal mask

What is it?

Causal maskImplemented

Attention

Every score above the diagonal is replaced with minus infinity. np.triu with k = 1 selects those positions, where the column j is greater than the row i. The diagonal is kept, so a position can read itself.

Why does the model need it?

Training asks each position to predict the next token. If a position could read later positions, it could read the answer. The mask rules that out, and it applies in the same way during generation.

Mathematics

Causal mask #Link to equation: Causal mask
Sij←{Sijj≤i−∞j>iS_{ij} \leftarrow \begin{cases} S_{ij} & j \le i \\ -\infty & j > i \end{cases}

What does each symbol mean?

SymbolMeaningIn codeShape
SijS_{ij}The score of position i reading position jS(H, T, T)
−∞-\inftyThe value written above the diagonal-np.infscalar

Tiny numerical example

S, head 0, after the maskIllustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values.
reads →row ↓ThecatsatonthematThecatsatonthematThe, The: -0.70-0.70The, cat: -inf (masked)-infThe, sat: -inf (masked)-infThe, on: -inf (masked)-infThe, the: -inf (masked)-infThe, mat: -inf (masked)-infcat, The: 0.950.95cat, cat: -0.78-0.78cat, sat: -inf (masked)-infcat, on: -inf (masked)-infcat, the: -inf (masked)-infcat, mat: -inf (masked)-infsat, The: -0.34-0.34sat, cat: 0.590.59sat, sat: 0.000.00sat, on: -inf (masked)-infsat, the: -inf (masked)-infsat, mat: -inf (masked)-infon, The: 1.001.00on, cat: 0.460.46on, sat: -0.40-0.40on, on: -0.52-0.52on, the: -inf (masked)-infon, mat: -inf (masked)-infthe, The: -0.60-0.60the, cat: 0.100.10the, sat: 0.170.17the, on: 0.550.55the, the: -0.40-0.40the, mat: -inf (masked)-infmat, The: 0.500.50mat, cat: 0.630.63mat, sat: -0.27-0.27mat, on: -0.02-0.02mat, the: 0.120.12mat, mat: 0.180.18

Low -0.78High 1.00

Data table
row \ readsThecatsatonthemat
The-0.70-inf (masked)-inf (masked)-inf (masked)-inf (masked)-inf (masked)
cat0.95-0.78-inf (masked)-inf (masked)-inf (masked)-inf (masked)
sat-0.340.590.00-inf (masked)-inf (masked)-inf (masked)
on1.000.46-0.40-0.52-inf (masked)-inf (masked)
the-0.600.100.170.55-0.40-inf (masked)
mat0.500.63-0.27-0.020.120.18

Tensor shapes

The mask has one layer of shape (T, T). NumPy applies it to every headtransformer.py:82
  1. S, scaled(H, T, T)(2, 16, 16)
  2. np.triu(..., 1)(T, T)(16, 16)
  3. S, masked(H, T, T)(2, 16, 16)

H = num_heads = 2. T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt.

Actual implementation

transformer.pyattention_forwardGitHub: transformer.py
    S = np.where(np.triu(np.ones((T, T), dtype=bool), 1), -np.inf, S)

Code and math connection

EquationRoleLinesCode
Causal maskComputes ittransformer.py:82S = np.where(np.triu(np.ones((T, T), dtype=bool), 1), -np.inf, S)
Causal maskTests ittests/test_transformer.py:85assert np.allclose(la[:4], lb[:4])

Where is it used?

Source

transformer.py, lines 70-86, attention_forward

PDF reference

Section 4.5, Eq. 43, p. 10

Used by

Called by: transformer.block_forward

Tested by: tests.test_transformer.test_attention_weights_are_causal_and_normalised

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Causal maskComputes ittransformer.pyattention_forward82GitHub: transformer.py, line 82

Softmax per row

What is it?

Softmax per rowImplemented

Attention

softmax_forward turns each row of masked scores into weights that are not negative and add up to 1. It works along the last axis, so each row of each head is computed on its own.

Why does the model need it?

The output of a position is a weighted average of value rows, so its weights need to add up to 1. Masked cells get a weight of exactly 0, so their values contribute nothing.

Mathematics

Weights, one row at a time #Link to equation: Weights, one row at a time
A=softmax⁡(S),∑jAij=1A = \operatorname{softmax}(S), \qquad \sum_{j} A_{ij} = 1
Why the shift is safe. The factor from any constant c cancels #Link to equation: Why the shift is safe. The factor from any constant c cancels
exi−c∑kexk−c=e−c exie−c∑kexk=exi∑kexk\frac{e^{x_i - c}}{\sum_k e^{x_k - c}} = \frac{e^{-c}\, e^{x_i}}{e^{-c} \sum_k e^{x_k}} = \frac{e^{x_i}}{\sum_k e^{x_k}}
A masked cell has a weight of exactly 0 #Link to equation: A masked cell has a weight of exactly 0
exp⁡(−∞)=0⇒Aij=0 for j>i\exp(-\infty) = 0 \quad\Rightarrow\quad A_{ij} = 0 \text{ for } j > i

A row always has one finite score, because the diagonal is never masked. So its largest score is finite, and a masked cell stays at minus infinity after the shift. The first row has only its own cell, so its weight there is 1.

What does each symbol mean?

SymbolMeaningIn codeShape
AAAttention weights, one matrix per headA(H, T, T)
AijA_{ij}How much of position j's value position i receivesA[h, i, j]scalar
ziz_iOne number in the row softmax receives. Here, one masked scorexscalar
ccThe constant subtracted from a row. The code uses the row's largest scorenp.max(x, axis=-1, keepdims=True)one per row

Tiny numerical example

A, head 0Illustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values.
reads →row ↓ThecatsatonthematThecatsatonthematThe, The: 1.0001.000The, cat: 0.000 (masked)0.000The, sat: 0.000 (masked)0.000The, on: 0.000 (masked)0.000The, the: 0.000 (masked)0.000The, mat: 0.000 (masked)0.000cat, The: 0.8490.849cat, cat: 0.1510.151cat, sat: 0.000 (masked)0.000cat, on: 0.000 (masked)0.000cat, the: 0.000 (masked)0.000cat, mat: 0.000 (masked)0.000sat, The: 0.2030.203sat, cat: 0.5110.511sat, sat: 0.2850.285sat, on: 0.000 (masked)0.000sat, the: 0.000 (masked)0.000sat, mat: 0.000 (masked)0.000on, The: 0.4870.487on, cat: 0.2850.285on, sat: 0.1210.121on, on: 0.1070.107on, the: 0.000 (masked)0.000on, mat: 0.000 (masked)0.000the, The: 0.1050.105the, cat: 0.2120.212the, sat: 0.2270.227the, on: 0.3290.329the, the: 0.1280.128the, mat: 0.000 (masked)0.000mat, The: 0.2170.217mat, cat: 0.2470.247mat, sat: 0.1000.100mat, on: 0.1290.129mat, the: 0.1490.149mat, mat: 0.1580.158

Low 0.100High 1.000

Data table
row \ readsThecatsatonthemat
The1.0000.000 (masked)0.000 (masked)0.000 (masked)0.000 (masked)0.000 (masked)
cat0.8490.1510.000 (masked)0.000 (masked)0.000 (masked)0.000 (masked)
sat0.2030.5110.2850.000 (masked)0.000 (masked)0.000 (masked)
on0.4870.2850.1210.1070.000 (masked)0.000 (masked)
the0.1050.2120.2270.3290.1280.000 (masked)
mat0.2170.2470.1000.1290.1490.158
RowSum of the rowMasked cells
The1.00005
cat1.00004
sat1.00003
on1.00002
the1.00001
mat1.00000

Tensor shapes

Softmax keeps the shape. It normalises along the last axistransformer.py:83
  1. S(H, T, T)(2, 16, 16)
  2. A(H, T, T)(2, 16, 16)

H = num_heads = 2. T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt.

Actual implementation

transformer.pyattention_forwardGitHub: transformer.py
    A = softmax_forward(S)
ops.pysoftmax_forwardGitHub: ops.py
def softmax_forward(x):    e = np.exp(x - np.max(x, axis=-1, keepdims=True))    return e / e.sum(axis=-1, keepdims=True)

Softmax & Cross-Entropy explains the shift by the largest value.

Code and math connection

EquationRoleLinesCode
Weights, one row at a timeCalls ittransformer.py:83A = softmax_forward(S)
Weights, one row at a timeComputes itops.py:24return e / e.sum(axis=-1, keepdims=True)
Weights, one row at a timeTests ittests/test_transformer.py:73assert np.allclose(A.sum(axis=-1), 1.0)
A masked cell has a weight of exactly 0Computes itops.py:23e = np.exp(x - np.max(x, axis=-1, keepdims=True))
A masked cell has a weight of exactly 0Calls ittransformer.py:83A = softmax_forward(S)
A masked cell has a weight of exactly 0Tests ittests/test_transformer.py:72assert np.allclose(np.triu(A[0], 1), 0.0) # nothing flows from the future

Where is it used?

Source

transformer.py, lines 70-86, attention_forward

PDF reference

Section 4.5, Eq. 43, p. 10

Used by

Called by: transformer.block_forward

Tested by: tests.test_transformer.test_attention_weights_are_causal_and_normalised

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Weights, one row at a timeComputes itops.pysoftmax_forward24GitHub: ops.py, line 24
A masked cell has a weight of exactly 0Computes itops.pysoftmax_forward23GitHub: ops.py, line 23

Weighted values

What is it?

Weighted valuesImplemented

Attention

Multiplying A by V_h gives each position a weighted sum of value rows, separately in each head. The weights of a row can only be non-zero up to that row, so the sum uses that position and earlier ones.

Why does the model need it?

This is the step where information moves between positions. Before it, each row was computed from its own input row alone. After it, a row also holds parts of the values of earlier rows.

Mathematics

Weighted values, one matrix per head #Link to equation: Weighted values, one matrix per head
AVhA V_h
The same product, for one row Derived from implementation#Link to equation: The same product, for one row
(AVh)i=∑j≤iAij(Vh)j(A V_h)_{i} = \sum_{j \le i} A_{ij} (V_h)_{j}

What does each symbol mean?

SymbolMeaningIn codeShape
VhV_hThe values of one headVh(T, d_h) per head
(Vh)j(V_h)_{j}Row j of the values of one headVh[h, j](d_h,)
AijA_{ij}The weight of position j, for position iA[h, i, j]scalar

Tiny numerical example

Weighted values, head 0Illustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values.
Vh[0], shape (6, 2)
-1.0050.715
0.971.09
-0.155-0.885
1.195-0.035
-0.9050.315
0.67-0.26
(A @ Vh)[0], shape (6, 2)
-1.0050.715
-0.70750.7715
0.24780.45
-0.10380.549
0.34280.1335
0.13070.3373

Row 2, "sat": 0.203 * [-1.005, 0.715] + 0.5115 * [0.97, 1.09] + 0.2855 * [-0.155, -0.885] = [0.2478, 0.45]

Tensor shapes

Weighted values have the shape of the valuestransformer.py:84
  1. A(H, T, T)(2, 16, 16)
  2. Vh(H, T, dh)(2, 16, 16)
  3. A @ Vh(H, T, dh)(2, 16, 16)

H = num_heads = 2. T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. dh = head_dim = 16.

Actual implementation

The product is the argument of merge_heads on this line. Like the scores, it is not stored on its own.

transformer.pyattention_forwardGitHub: transformer.py
    O = merge_heads(A @ Vh)

Code and math connection

EquationRoleLinesCode
Weighted values, one matrix per headComputes ittransformer.py:84O = merge_heads(A @ Vh)
The same product, for one rowComputes ittransformer.py:84O = merge_heads(A @ Vh)

Where is it used?

Source

transformer.py, lines 70-86, attention_forward

PDF reference

Section 4.5, Eq. 44, p. 10

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Weighted values, one matrix per headComputes ittransformer.pyattention_forward84GitHub: transformer.py, line 84
The same product, for one rowComputes ittransformer.pyattention_forward84GitHub: transformer.py, line 84

Merging heads

What is it?

Merging headsImplemented

Attention

merge_heads undoes the split. It swaps the first two axes back, then reshapes, so the weighted values of the heads sit side by side in one row of width d again.

Why does the model need it?

The output projection and everything after attention work on rows of width d. Merging restores that width, and the output projection is then the first step that can combine numbers from different heads.

Mathematics

Heads side by side #Link to equation: Heads side by side
O=merge⁡(AVh)=[A(0)V(0)⋯A(H−1)V(H−1)]O = \operatorname{merge}(A V_h) = \begin{bmatrix} A^{(0)} V^{(0)} & \cdots & A^{(H-1)} V^{(H-1)} \end{bmatrix}

What does each symbol mean?

SymbolMeaningIn codeShape
OOThe merged weighted valuesO(T, d)
A(h),V(h)A^{(h)}, V^{(h)}The weights and values of head hA[h], Vh[h](T, T) and (T, d_h)

Tiny numerical example

Two heads, side by sideIllustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values.
(A @ Vh)[0], shape (6, 2)
-1.0050.715
-0.70750.7715
0.24780.45
-0.10380.549
0.34280.1335
0.13070.3373
(A @ Vh)[1], shape (6, 2)
0.365-0.395
0.0018-0.2529
0.12720.0313
-0.05950.095
0.22180.278
0.17190.2974
O, shape (6, 4)
-1.0050.7150.365-0.395
-0.70750.77150.0018-0.2529
0.24780.450.12720.0313
-0.10380.549-0.05950.095
0.34280.13350.22180.278
0.13070.33730.17190.2974

Tensor shapes

The transpose, then the reshape, in merge_headstransformer.py:67
  1. A @ Vh(H, T, dh)(2, 16, 16)
  2. transpose(T, H, dh)(16, 2, 16)
  3. O(T, d)(16, 32)

H = num_heads = 2. T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. dh = head_dim = 16. d = emb_dim = 32.

Actual implementation

transformer.pymerge_headsGitHub: transformer.py
def merge_heads(Mh):    H, T, dh = Mh.shape    return Mh.transpose(1, 0, 2).reshape(T, H * dh)

Code and math connection

EquationRoleLinesCode
Heads side by sideCalls ittransformer.py:84O = merge_heads(A @ Vh)
Heads side by sideComputes ittransformer.py:67return Mh.transpose(1, 0, 2).reshape(T, H * dh)
Heads side by sideTests ittests/test_transformer.py:63assert np.array_equal(merge_heads(Mh), M)

Where is it used?

Source

transformer.py, lines 65-67, merge_heads

PDF reference

Section 4.5, Eq. 45, p. 10

Used by

Called by: transformer.attention_backward, transformer.attention_forward

Tested by: tests.test_transformer.test_split_merge_heads_round_trip

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Heads side by sideComputes ittransformer.pymerge_heads67GitHub: transformer.py, line 67

Output projection

What is it?

Output projectionImplemented

Attention

A last linear layer turns the merged values into the output of attention. It has its own weight matrix W_o and bias b_o, and it keeps the shape.

Why does the model need it?

Until this point each head's columns have stayed apart. W_o multiplies across all d columns, so each output number can draw on every head.

Mathematics

Output projection #Link to equation: Output projection
out=OWo+1bo⊤\mathrm{out} = O W_o + \mathbf{1} b_o^\top

What does each symbol mean?

SymbolMeaningIn codeShape
WoW_oOutput weight matrixparams[p + "wo"](d, d)
bob_oOutput bias, added to every rowparams[p + "bo"](d,)
out\mathrm{out}The output of attention, added to the block's input by the residualout(T, d)

Tiny numerical example

The output projectionIllustrative ExampleHand-picked numbers, one row per word of the sentence. They do not encode the words and are not learned values.
O, shape (6, 4)
-1.0050.7150.365-0.395
-0.70750.77150.0018-0.2529
0.24780.450.12720.0313
-0.10380.549-0.05950.095
0.34280.13350.22180.278
0.13070.33730.17190.2974
wo, shape (4, 4)
0.6-0.20.10.3
0.20.4-0.50.1
-0.10.30.5-0.2
0.40.10.20.6
out, shape (6, 4)
-0.66450.587-0.3345-0.56
-0.38150.4554-0.4862-0.3072
0.22850.2017-0.11040.0927
0.08140.262-0.27560.0726
0.31140.10920.15410.2186
0.23770.22010.00980.197

out = O @ wo + bo, with bo = [-0.01, 0.03, 0.02, -0.02] added to every row.

Tensor shapes

The output has the shape of the input Xntransformer.py:85
  1. O(T, d)(16, 32)
  2. wo(d, d)(32, 32)
  3. out(T, d)(16, 32)

T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. d = emb_dim = 32.

Actual implementation

transformer.pyattention_forwardGitHub: transformer.py
    out = O @ params[p + "wo"] + params[p + "bo"]

Code and math connection

EquationRoleLinesCode
Output projectionComputes ittransformer.py:85out = O @ params[p + "wo"] + params[p + "bo"]

Where is it used?

Source

transformer.py, lines 70-86, attention_forward

PDF reference

Section 4.5, Eq. 45, p. 10

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Output projectionComputes ittransformer.pyattention_forward85GitHub: transformer.py, line 85

The transformer.py implementation

This is the whole function, with its real line numbers. Highlighted lines have a link record behind them.

Code listing: transformer.attention_forward
transformer.pyattention_forwardGitHub: transformer.py
def attention_forward(Xn, params, p, cfg: ModelConfig):    T, d = Xn.shape    H = cfg.num_heads    dh = d // H # width of one head    Q = Xn @ params[p+"wq"] + params[p+"bq"]    # (T,d), QUERY    K = Xn @ params[p+"wk"] + params[p+"bk"]    # KEY    V = Xn @ params[p+"wv"] + params[p+"bv"]    # VALUE    Qh, Kh, Vh = split_heads(Q, H), split_heads(K, H), split_heads(V, H)    S = (Qh @ Kh.transpose(0, 2, 1)) / np.sqrt(dh)    S = np.where(np.triu(np.ones((T, T), dtype=bool), 1), -np.inf, S)    A = softmax_forward(S)    O = merge_heads(A @ Vh)    out = O @ params[p + "wo"] + params[p + "bo"]    return out, dict(Xn=Xn, Qh=Qh, Kh=Kh, Vh=Vh, A=A, O=O)

Two things to note when reading it:

  • S is assigned twice. The first assignment holds the scaled scores, and the second overwrites them with the masked scores.
  • The returned dictionary is the cache that the backward pass reads. It holds Xn, Qh, Kh, Vh, A and O. It does not hold S, Q, K or V.

Tensor shapes with the real config

These are the shapes from running the real functions at the values in config.py: T = 16, d = 32, H = 2 and d_h = 16. T can be smaller during generation, for a short prompt.

Forward, with the real configIllustrative ExampleSizes from config.py. These shapes are what the real functions returned on random inputs. No value from that run is shown.
Xn(16, 32)Q(16, 32)K(16, 32)V(16, 32)Qh(2, 16, 16)Kh(2, 16, 16)Vh(2, 16, 16)S(2, 16, 16)A(2, 16, 16)A @ Vh(2, 16, 16)O(16, 32)out(16, 32)
Backward, with the real configIllustrative ExampleSizes from config.py. These shapes are what the real functions returned on random inputs. No value from that run is shown.
dOut(16, 32)dOh(2, 16, 16)dVh(2, 16, 16)dA(2, 16, 16)dS(2, 16, 16)dQh(2, 16, 16)dKh(2, 16, 16)dQ(16, 32)dK(16, 32)dV(16, 32)dXn(16, 32)
Entry of gradsShape
grads[p + "wq"](32, 32)
grads[p + "bq"](32,)
grads[p + "wk"](32, 32)
grads[p + "bk"](32,)
grads[p + "wv"](32, 32)
grads[p + "bv"](32,)
grads[p + "wo"](32, 32)
grads[p + "bo"](32,)

The model has no batch axis here. H is the only axis attention adds, and it comes and goes inside this function. config.head_dim is not read by this code, which computes the width again with d // H (transformer.py:73).

Backward pass

What is it?

Attention backwardImplemented

Attention

attention_backward receives dOut, the gradient of the loss on the output of attention. It fills the gradients of all eight attention parameters and returns dXn, the gradient on the input. It runs the forward steps in reverse, reading the values the forward pass left in its cache.

Why does the model need it?

W_q, W_k, W_v, W_o and their biases are parameters, so the optimizer needs a gradient for each. Earlier layers also need the gradient to continue, so dXn goes on to the first LayerNorm's backward.

Mathematics

First the gradient goes through the output projection, and is split into heads the way the merged values were:

Gradient on the output weights #Link to equation: Gradient on the output weights
dWo=O⊤ dOutdW_o = O^\top\, d\mathrm{Out}
Gradient on the output bias #Link to equation: Gradient on the output bias
dbo=1⊤dOutdb_o = \mathbf{1}^\top d\mathrm{Out}
Through the output projection, then the head split #Link to equation: Through the output projection, then the head split
dOh=split⁡(dOut Wo⊤,H)dO_h = \operatorname{split}(d\mathrm{Out}\, W_o^\top, H)

The product A V_h depends on both factors, so the gradient goes to each of them:

Gradient on the values #Link to equation: Gradient on the values
dVh=A⊤dOhdV_h = A^\top dO_h
Gradient on the weights #Link to equation: Gradient on the weights
dA=dOhVh⊤dA = dO_h V_h^\top

Then it goes back through softmax, to the scores:

Through softmax, softmax_backward(A, dA) #Link to equation: Through softmax, softmax_backward(A, dA)
dSij=Aij(dAij−∑kAik dAik)dS_{ij} = A_{ij}\Big(dA_{ij} - \sum_{k} A_{ik}\, dA_{ik}\Big)

Why masked positions get no gradient. softmax_backward multiplies its result by the weights. A masked position has a weight of exactly 0, so whatever dA holds in that cell, dS is exactly 0 there. Its contribution to dVh is also 0, because A^T carries the same 0. The rest of the backward pass adds nothing from that cell, so the output at a position sends no gradient to any later position.

A masked cell gets no gradient Derived from implementation#Link to equation: A masked cell gets no gradient
Aij=0⇒dSij=0A_{ij} = 0 \quad\Rightarrow\quad dS_{ij} = 0

From the scores, the gradient goes to the queries and keys. The scale appears again here, because the forward pass divided by it:

Gradient on the queries #Link to equation: Gradient on the queries
dQh=dS KhdhdQ_h = \frac{dS\, K_h}{\sqrt{d_h}}
Gradient on the keys #Link to equation: Gradient on the keys
dKh=dS⊤QhdhdK_h = \frac{dS^\top Q_h}{\sqrt{d_h}}
Heads side by side again Derived from implementation#Link to equation: Heads side by side again
dQ, dK, dV=merge⁡(dQh), merge⁡(dKh), merge⁡(dVh)dQ,\ dK,\ dV = \operatorname{merge}(dQ_h),\ \operatorname{merge}(dK_h),\ \operatorname{merge}(dV_h)

Each of the three projections is a linear layer of the same input. So each gets a parameter gradient, and the gradients on the input add up:

Gradient on the three weight matrices #Link to equation: Gradient on the three weight matrices
dWn=Xn⊤dMn,n∈{q,k,v}dW_n = X_n^\top dM_n, \qquad n \in \{q, k, v\}
Gradient on the three biases #Link to equation: Gradient on the three biases
dbn=1⊤dMn,n∈{q,k,v}db_n = \mathbf{1}^\top dM_n, \qquad n \in \{q, k, v\}
Gradient on the input, summed over q, k and v #Link to equation: Gradient on the input, summed over q, k and v
dXn=dQ Wq⊤+dK Wk⊤+dV Wv⊤dX_n = dQ\, W_q^\top + dK\, W_k^\top + dV\, W_v^\top

The key bias gets a gradient of zero. Each row of dS adds up to 0, because softmax ignores a constant added to a row. Adding b_k to every key adds the same amount to every score in a row of S, so it cannot change the weights.

The key bias has a gradient of zero #Link to equation: The key bias has a gradient of zero
dbk=1⊤dK=0db_k = \mathbf{1}^\top dK = 0

What does each symbol mean?

SymbolMeaningIn codeShape
dOutd\mathrm{Out}Gradient of the loss on the output of attentiondOut(T, d)
dOhdO_hGradient on the merged values, split into headsdOh(H, T, d_h)
dVhdV_hGradient on the values, per headdVh(H, T, d_h)
dAdAGradient on the weightsdA(H, T, T)
dSdSGradient on the scaled, masked scoresdS(H, T, T)
dQh,dKhdQ_h, dK_hGradient on the queries and keys, per headdQh, dKh(H, T, d_h)
dQ,dK,dVdQ, dK, dVThe same three gradients after mergingdQ, dK, dV(T, d)
dMndM_nOne of dQ, dK or dV, as the loop in the code takes them in turndM(T, d)
nnThe name of a projection, q, k or vnscalar
dXndX_nGradient on the input of attentiondXn(T, d)
dWo,dbo,dWn,dbndW_o, db_o, dW_n, db_nParameter gradients, stored in gradsgrads[p + "w" + n], grads[p + "b" + n](d, d) and (d,)

Tiny numerical example

One backward pass, head 0Illustrative ExampleThe same hand-picked forward pass as above. dOut is also hand-picked, not computed from a loss.
dOut, shape (6, 4)
0.1-0.20.30.05
-0.10.250.15-0.3
0.20.1-0.250.15
-0.050.30.1-0.2
0.15-0.10.20.25
0.3-0.15-0.10.1
dOh[0], shape (6, 2)
0.145-0.205
-0.185-0.025
0.120.22
-0.140.04
0.205-0.085
0.230.06
dVh[0], shape (6, 2)
0.0155-0.1579
0.09370.117
0.08690.0544
0.0822-0.016
0.0604-0.0019
0.03620.0095
dA[0], shape (6, 6)
-0.2923-0.08280.1590.1804-0.19580.1505
0.1681-0.20670.0508-0.22020.1595-0.1174
0.03670.3562-0.21330.1357-0.03930.0232
0.1693-0.0922-0.0137-0.16870.1393-0.1042
-0.26680.10620.04350.248-0.21230.1595
-0.18830.2885-0.08870.2727-0.18930.1385
dS[0], shape (6, 6)
000000
0.0479-0.04790000
-0.01870.1163-0.0976000
0.0647-0.0367-0.0061-0.021900
-0.03410.01-0.00350.0623-0.03470
-0.05180.0589-0.0140.0287-0.03560.0139
dQh[0], shape (6, 2)
00
0.0373-0.0509
0.04760.0975
0.0747-0.0626
-0.0930.0761
-0.07740.1049
dKh[0], shape (6, 2)
0.0551-0.0699
-0.02930.1283
-0.0035-0.0634
-0.02420.0217
-0.0046-0.0227
0.00650.0059
dXn, shape (6, 4)
-0.10360.12330.075-0.0257
0.16020.06520.00380.1086
-0.03660.13790.0064-0.0246
0.00510.0798-0.07010.0543
-0.00980.04510.04910.0041
-0.00950.04430.0254-0.0254
A masked cell gets no gradientIllustrative ExampleRow 1, column 2 of head 0. Position 1 may not read position 2, so this cell is masked.
QuantityCodeValue
WeightA[0, 1, 2]0
Gradient on the weightdA[0, 1, 2]0.0508
Gradient on the scoredS[0, 1, 2]0
Where the results goIllustrative ExampleThe same run. grads holds the parameter gradients, and dXn is returned to block_backward.
grads[p + "wo"], shape (4, 4)
0.1156-0.0361-0.42450.3187
0.17810.19560.2656-0.1709
0.1496-0.12560.09920.1213
0.1182-0.025-0.12890.1411
grads[p + "wq"], shape (4, 4)
-0.0465-0.20820.0324-0.0677
0.05950.11420.01350.0312
-0.18190.23910.06490.0706
0.135-0.15480.005-0.0955
grads[p + "wk"], shape (4, 4)
-0.00920.1739-0.07960.0537
-0.0760.06980.0373-0.0921
0.1168-0.24360.00590.0985
-0.04650.293-0.0286-0.0442
grads[p + "wv"], shape (4, 4)
0.0189-0.05370.273-0.1431
0.17670.1939-0.1149-0.023
0.007-0.2518-0.02070.4042
0.17750.1820.3727-0.1745

The largest entry of grads[p + "bk"] is 2.9e-17, which is round-off around 0.

Checking the backward numericallyIllustrative ExampleA test-time idea, not part of the pipeline: gradcheck.numerical_grad is only called by tests. It nudges each number up and down by h = 1e-5 and measures how (out * dOut).sum(), whose gradient on out is exactly dOut, changes. Tests compare the central differences here with that real function.
GradientFrom the backward formulaFrom central differencesLargest gap
grads[p + "wo"][0.1156, -0.0361, -0.4245, 0.3187][0.1781, 0.1956, 0.2656, -0.1709][0.1496, -0.1256, 0.0992, 0.1213][0.1182, -0.0250, -0.1289, 0.1411][0.1156, -0.0361, -0.4245, 0.3187][0.1781, 0.1956, 0.2656, -0.1709][0.1496, -0.1256, 0.0992, 0.1213][0.1182, -0.0250, -0.1289, 0.1411]3.1e-12
grads[p + "wq"][-0.0465, -0.2082, 0.0324, -0.0677][0.0595, 0.1142, 0.0135, 0.0312][-0.1819, 0.2391, 0.0649, 0.0706][0.1350, -0.1548, 0.0050, -0.0955][-0.0465, -0.2082, 0.0324, -0.0677][0.0595, 0.1142, 0.0135, 0.0312][-0.1819, 0.2391, 0.0649, 0.0706][0.1350, -0.1548, 0.0050, -0.0955]7.4e-12
grads[p + "wk"][-0.0092, 0.1739, -0.0796, 0.0537][-0.0760, 0.0698, 0.0373, -0.0921][0.1168, -0.2436, 0.0059, 0.0985][-0.0465, 0.2930, -0.0286, -0.0442][-0.0092, 0.1739, -0.0796, 0.0537][-0.0760, 0.0698, 0.0373, -0.0921][0.1168, -0.2436, 0.0059, 0.0985][-0.0465, 0.2930, -0.0286, -0.0442]7.1e-12
grads[p + "wv"][0.0189, -0.0537, 0.2730, -0.1431][0.1767, 0.1939, -0.1149, -0.0230][0.0070, -0.2518, -0.0207, 0.4042][0.1775, 0.1820, 0.3727, -0.1745][0.0189, -0.0537, 0.2730, -0.1431][0.1767, 0.1939, -0.1149, -0.0230][0.0070, -0.2518, -0.0207, 0.4042][0.1775, 0.1820, 0.3727, -0.1745]7.1e-12
dXn[-0.1036, 0.1233, 0.0750, -0.0257][0.1602, 0.0652, 0.0038, 0.1086][-0.0366, 0.1379, 0.0064, -0.0246][0.0051, 0.0798, -0.0701, 0.0543][-0.0098, 0.0451, 0.0491, 0.0041][-0.0095, 0.0443, 0.0254, -0.0254][-0.1036, 0.1233, 0.0750, -0.0257][0.1602, 0.0652, 0.0038, 0.1086][-0.0366, 0.1379, 0.0064, -0.0246][0.0051, 0.0798, -0.0701, 0.0543][-0.0098, 0.0451, 0.0491, 0.0041][-0.0095, 0.0443, 0.0254, -0.0254]8.8e-12

Tensor shapes

From dOut to the valuestransformer.py:98
  1. dOut(T, d)(16, 32)
  2. dOh(H, T, dh)(2, 16, 16)
  3. dVh(H, T, dh)(2, 16, 16)
  4. dV(T, d)(16, 32)

T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. d = emb_dim = 32. H = num_heads = 2. dh = head_dim = 16.

From dOh through the weights and scores to the queries. Keys go the same waytransformer.py:101
  1. dOh(H, T, dh)(2, 16, 16)
  2. dA(H, T, T)(2, 16, 16)
  3. dS(H, T, T)(2, 16, 16)
  4. dQh(H, T, dh)(2, 16, 16)
  5. dQ(T, d)(16, 32)

H = num_heads = 2. T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. dh = head_dim = 16. d = emb_dim = 32.

Parameter gradient and input gradient, for one projectiontransformer.py:108
  1. dM(T, d)(16, 32)
  2. grads wn(d, d)(32, 32)
  3. dXn(T, d)(16, 32)

T = seq_len = 16. T is at most seq_len: generation uses the last seq_len ids, or fewer for a short prompt. d = emb_dim = 32.

Actual implementation

transformer.pyattention_backwardGitHub: transformer.py
def attention_backward(dOut, c, params, p, cfg: ModelConfig, grads):    H = cfg.num_heads    dh = cfg.emb_dim // H    grads[p + "wo"] += c["O"].T @ dOut    grads[p + "bo"] += dOut.sum(axis=0)    dOh = split_heads(dOut @ params[p + "wo"].T, H)    dVh = c["A"].transpose(0, 2, 1) @ dOh     dA = dOh @ c["Vh"].transpose(0, 2, 1)    dS = softmax_backward(c["A"], dA)    dQh = dS @ c["Kh"] / np.sqrt(dh)    dKh = dS.transpose(0, 2, 1) @ c["Qh"] / np.sqrt(dh)    dQ, dK, dV = merge_heads(dQh), merge_heads(dKh), merge_heads(dVh)    dXn = np.zeros_like(c["Xn"])    for n, dM in (("q", dQ), ("k", dK), ("v", dV)):        grads[p + "w" + n] += c["Xn"].T @ dM        grads[p + "b" + n] += dM.sum(axis=0)        dXn += dM @ params[p + "w" + n].T    return dXn

This function writes the linear layer's gradient formulas in place and does not call linear_backward.

Code and math connection

EquationRoleLinesCode
Gradient on the output weightsComputes ittransformer.py:95grads[p + "wo"] += c["O"].T @ dOut
Gradient on the output biasComputes ittransformer.py:96grads[p + "bo"] += dOut.sum(axis=0)
Through the output projection, then the head splitComputes ittransformer.py:97dOh = split_heads(dOut @ params[p + "wo"].T, H)
Gradient on the valuesComputes ittransformer.py:98dVh = c["A"].transpose(0, 2, 1) @ dOh
Gradient on the weightsComputes ittransformer.py:99dA = dOh @ c["Vh"].transpose(0, 2, 1)
Through softmax, softmax_backward(A, dA)Calls ittransformer.py:100dS = softmax_backward(c["A"], dA)
Through softmax, softmax_backward(A, dA)Computes itops.py:27return y * (dY - np.sum(y * dY, axis=-1, keepdims=True))
A masked cell gets no gradientComputes itops.py:27return y * (dY - np.sum(y * dY, axis=-1, keepdims=True))
Gradient on the queriesComputes ittransformer.py:101dQh = dS @ c["Kh"] / np.sqrt(dh)
Gradient on the keysComputes ittransformer.py:102dKh = dS.transpose(0, 2, 1) @ c["Qh"] / np.sqrt(dh)
Heads side by side againComputes ittransformer.py:103dQ, dK, dV = merge_heads(dQh), merge_heads(dKh), merge_heads(dVh)
Gradient on the three weight matricesComputes ittransformer.py:106grads[p + "w" + n] += c["Xn"].T @ dM
Gradient on the three biasesComputes ittransformer.py:107grads[p + "b" + n] += dM.sum(axis=0)
Gradient on the input, summed over q, k and vComputes ittransformer.py:108dXn += dM @ params[p + "w" + n].T
Gradient on the input, summed over q, k and vCalls ittransformer.py:148dXn = attention_backward(dX1, c["att"], params, p, cfg, grads)
The key bias has a gradient of zeroComputes ittransformer.py:107grads[p + "b" + n] += dM.sum(axis=0)
The key bias has a gradient of zeroTests ittests/test_gradcheck.py:39assert np.abs(grads[f"b{l}.bk"]).max() < 1e-12

Where is it used?

block_backward calls it with the gradient after the residual (transformer.py:147, transformer.py:148).

Source

transformer.py, lines 92-109, attention_backward

PDF reference

Section 4.5, Eq. 46, 47, 48, 49, 50, p. 10

Used by

Called by: transformer.block_backward

Status
Implemented

Implemented in

EquationRoleFileFunctionLinesGitHub
Gradient on the output weightsComputes ittransformer.pyattention_backward95GitHub: transformer.py, line 95
Gradient on the output biasComputes ittransformer.pyattention_backward96GitHub: transformer.py, line 96
Through the output projection, then the head splitComputes ittransformer.pyattention_backward97GitHub: transformer.py, line 97
Gradient on the valuesComputes ittransformer.pyattention_backward98GitHub: transformer.py, line 98
Gradient on the weightsComputes ittransformer.pyattention_backward99GitHub: transformer.py, line 99
Through softmax, softmax_backward(A, dA)Computes itops.pysoftmax_backward27GitHub: ops.py, line 27
A masked cell gets no gradientComputes itops.pysoftmax_backward27GitHub: ops.py, line 27
Gradient on the queriesComputes ittransformer.pyattention_backward101GitHub: transformer.py, line 101
Gradient on the keysComputes ittransformer.pyattention_backward102GitHub: transformer.py, line 102
Heads side by side againComputes ittransformer.pyattention_backward103GitHub: transformer.py, line 103
Gradient on the three weight matricesComputes ittransformer.pyattention_backward106GitHub: transformer.py, line 106
Gradient on the three biasesComputes ittransformer.pyattention_backward107GitHub: transformer.py, line 107
Gradient on the input, summed over q, k and vComputes ittransformer.pyattention_backward108GitHub: transformer.py, line 108
The key bias has a gradient of zeroComputes ittransformer.pyattention_backward107GitHub: transformer.py, line 107

How this reaches a parameter update. grads is filled in place. batch_loss_and_grads averages the gradients over the batch (train.py:61), clip_grad_norm may scale them (train.py:62), and opt.step uses them (train.py:66). The weight matrices also get weight decay, and the biases do not. dXn goes on to the first LayerNorm's backward (transformer.py:149).

Role in the model

Attention is the first branch of each block. block_forward normalises its input, runs attention on that copy, and adds the result to the unnormalised input:

transformer.pyblock_forwardGitHub: transformer.py
    att, c_att = attention_forward(Xn, params, p, cfg) # each position gathers info from earlier positions
transformer.pyblock_forwardGitHub: transformer.py
    X1 = X + att

The model has 2 blocks, and each has its own eight attention parameters:

NameShapeWith configScalarsRole
b{l}.wqtransformer.py:32(d, d)(32, 32)d × d1,024 per blockQuery projection weights.Starts as Xavier uniform. Weight decay.
b{l}.bqtransformer.py:33(d,)(32,)d32 per blockQuery projection bias.Starts as zeros. No decay.
b{l}.wktransformer.py:32(d, d)(32, 32)d × d1,024 per blockKey projection weights.Starts as Xavier uniform. Weight decay.
b{l}.bktransformer.py:33(d,)(32,)d32 per blockKey projection bias.Starts as zeros. No decay.
b{l}.wvtransformer.py:32(d, d)(32, 32)d × d1,024 per blockValue projection weights.Starts as Xavier uniform. Weight decay.
b{l}.bvtransformer.py:33(d,)(32,)d32 per blockValue projection bias.Starts as zeros. No decay.
b{l}.wotransformer.py:32(d, d)(32, 32)d × d1,024 per blockAttention output projection weights.Starts as Xavier uniform. Weight decay.
b{l}.botransformer.py:33(d,)(32,)d32 per blockAttention output projection bias.Starts as zeros. No decay.

Attention is the only step in a block that moves information between positions. The feed-forward layer that follows works on each row alone. Because the mask is applied in every block, the output at a position depends only on that position and earlier ones, all the way through the model. A test checks this on the whole model, by changing only the last token and comparing the earlier logits (tests/test_transformer.py:85).

Next: Transformer Block shows how the branches fit together.