sweedworks

Looking at the right thing

The model on the previous page reads a fixed window and wires every position to the output separately. That is the ceiling it hits. Attention removes it by choosing where to look, and unlike almost anything else inside a model, the choice is a number per character that you can read off.

Sixth in the series, and the last component. Everything here is one attention head, 1,618 parameters, gradients written out by hand. 12 August 2026.

A question a window cannot answer

Here is a task built to need memory rather than pattern. Each line pairs letters with digits and then asks for one of them again:

d9 f2 b3 d9
b6 a8 c8 b6
e6 d9 b9 e6
c2 d7 b2 b2

900 lines to train on, 200 held back. Guessing gives 10%. The distance back to the answer varies, so no fixed offset works.

The fixed-window model tries hard

First, the model from the previous piece, given a window of 16 characters — the whole line, so it is not being starved of information. It has 9,570 parameters, and it learns a surprising amount:

What it got rightRate
A digit belongs here97%
One of the three values on this line75%
Which of those three it is31%
Held-out accuracy after 3,000 steps. Picking at random among the three values present would give 33%.

It learns the format perfectly and narrows the answer to the right three candidates most of the time. Then it stops. Choosing between them requires finding which pair began with the queried letter, and the position of that pair changes from line to line. A flattened window has one weight per position, so the only rules it can express are of the form the character at offset seven matters. There is no offset that is right every time.

One head, and why it also fails

So: attention. Build a query from the current position, compare it against a key at every position, softmax the comparisons into weights, and take a weighted average of the values there. Which position matters is decided from the content, at run time.

x_i     = C[char_i] + P[i]        embedding + position
q       = x_last @ Wq             one query, from where we are now
k_i     = x_i @ Wk                a key at every position
v_i     = x_i @ Wv                a value at every position
score_i = (q . k_i) / sqrt(16)
w       = softmax(score)          how much to look at each position
context = sum_i w_i v_i
logits  = context @ Wo + b
1,618 parameters — 6 times fewer than the model above.

I built that, trained it, and it scored 12%. Chance is 10%. It is worse than the fixed window it was supposed to beat.

The gradients were not wrong — they check out to 2e-08 against finite differences. The architecture cannot do this task, and the reason is worth more than the result. To answer, the head must end up attending to the digit, because the digit is what gets copied. But that position's key is built from the digit itself. Nothing about the query character d makes it match a key built from 9. There is no arrangement of these weights that solves it.

What the second layer is for

Real transformers do this with two layers. The first one does something that sounds trivial: at every position, it copies information about the previous character forward. After that, a position holding 9 also carries a trace of the d that came before it — and now a query built from d has something to match. The second layer does the matching and reads off the value. The pair is called an induction head, and it is one of the few things inside a large model that has been pinned down mechanically.

Implementing two layers here would multiply the code and hide the point, so instead I supplied what the first layer would produce: each position's value carries the next character rather than its own. One line different. Everything else — the query, the keys, the matching, the softmax — is unchanged and still learned from scratch.

ModelParametersHeld-out accuracy
Fixed window, 16 characters9,57031%
One head, values from their own position1,61812%
One head, values carrying the next character1,618100%
Same task, same data, same 3,000 training steps. Chance is 10%.

Perfect, with 6 times fewer parameters than the model that managed 31%. Not because it is bigger — because the operation matches the problem.

Watching it choose

Here is the part that is hard to get from anything else. The attention weights are the model's own account of where it looked, and there is one per character. These three lines query a pair in a different place each time:

d9 f2 b3 d9 — queried the first pair, furthest back

d999f2b3d1

Peak 99% on position 6, the earlier d. Model answered 9.

a9 d7 b0 d7 — queried the second pair

a9d1007b0d

Peak 100% on position 9, the earlier d. Model answered 7.

c2 d7 b2 b2 — queried the third pair, nearest

c2d7b982b2

Peak 98% on position 12, the earlier b. Model answered 2.

Shading is the attention weight; the number is the percentage. The window is padded with newlines on the left.

The peak lands at position 6, then 9, then 12 — it moves to wherever the matching letter is. Nothing in the weights encodes those positions. The head compares the query against every key and the softmax does the rest, which is exactly the thing the fixed window could not express.

Look inside it yourself

This is the trained head — all 1,618 numbers of it, loaded into the page. Change the line and watch the weights move. It only knows the characters from its task: letters af, digits, and spaces.

predicted next character

What this is not, again

One head, at one position, in one layer. A transformer runs this at every position at once, with several heads in parallel looking for different things, stacked in dozens of layers with a feed-forward network between each, and it learns the previous-character step rather than being handed it. What does not change with any of that is the operation: compare a query to keys, softmax, take a weighted average.

Six pieces ago this series started with a word being chopped into st, raw and berry. Between there and here is every component of a language model except scale: what it reads, where those pieces came from, what the thing in the middle is, how it decides where to look, how the next piece gets chosen, and what all of it costs. None of it required trusting me — every number came from a script you can read, and every tool runs on text of your own.


Both implementations are published: attn.py and attn.js, neither using an autodiff or matrix library. The build checks each one's gradients against finite differences and checks the two agree on initialisation, forward pass and the attention weights themselves — see checkattn.py. The task generator is task.py.