Transformers, ELI5

A language model like GPT or Claude does exactly one thing, over and over: guess the next word. This page builds that guess from scratch. Every step comes with the reason it exists and a small calculation you can check by hand.

Then follow every number through one block, see how the weights are learned, and why generating is fast.

One guess

Given a sequence of tokens (words, pieces of words, punctuation), the model outputs a probability for every possible next token. Given “the cat sat”, it might give on 40%, down 25%, and spread the rest over thousands of other tokens. Pick one, append it, and ask again. That is how it writes a paragraph.

So to understand a transformer, you only need to understand one guess: a sequence goes in, probabilities come out. Almost all the arithmetic inside is the dot product: multiply two lists pairwise and add them up.

(1,2)⋅(3,4)=1⋅3+2⋅4=11(1,2)\cdot(3,4)=1\cdot3+2\cdot4=11

A matrix multiply is a grid of dot products. A large model does billions of them for every token.

Words become vectors

Arithmetic needs numbers. A tokenizer gives each token an ID, but an ID is only an address: token 100 is not “twice” token 50. So each ID is used only to look up a row in a learned table, the embedding. Here are two invented rows of four numbers:

  • cat → (0.9, 0.3, -0.2, 0.6)
  • dog → (0.8, 0.4, -0.1, 0.5)

The table starts random. Training nudges it, and tokens used in similar contexts drift toward similar vectors, because similar vectors lead to similar predictions.

Pick some text. Long or unusual words split into pieces; every piece is one row of the table.

Similar means pointing the same way

A vector is an arrow. The dot product measures how much two arrows agree:

a⋅b=∥a∥ ∥b∥cos⁡θa\cdot b=\lVert a\rVert\,\lVert b\rVert\cos\theta

Here ∥a∥\lVert a\rVert is the arrow’s length and θ\theta the angle between the arrows. For the rows above,

cat⋅dog=0.72+0.12+0.02+0.30=1.16,\text{cat}\cdot\text{dog}=0.72+0.12+0.02+0.30=1.16,

a large positive number, since they point almost the same way. Dividing by both lengths leaves cos⁡θ\cos\theta, the cosine similarity, which compares direction only.

The amber arrow h is scored against four word arrows. Cosine picks the one pointing most nearly along h; the dot product also rewards length, so at 10° they disagree. Turn h to find other splits.

Where each word sits

Attention (next section) treats the rows as a set: nothing in it looks at a row’s index. Shuffle the input and every word gets the same new vector, just in a different order. Without help, “dog bites man” and “man bites dog” would look identical to it.

The fix is to put position into the rows. The simplest version learns one vector per position and adds it:

xi=embed(tokeni)+posix_i=\text{embed}(\text{token}_i)+\text{pos}_i

Most current models use RoPE instead: they rotate each query and key by an angle proportional to its position, so that when position ii meets position jj only the offset i−ji-j survives.

Attention

Now “the cat sat” is a grid with one row per token. But each row describes only its own token: the row for sat doesn’t know a cat did the sitting. To predict what comes next, a position needs information from other positions, and which positions matter depends on the text. So the mixing weights must be computed from the rows themselves.

A soft lookup

Think of a dictionary. You bring a query, compare it with each entry’s key, and get back that entry’s value. Attention softens this: score every key, turn the scores into weights that sum to 1, and return the weighted average of the values. Each row xx makes all three with learned matrices:

  • Query q=xWQq=xW_Q: what this position is looking for.
  • Key k=xWKk=xW_K: what it offers to be matched on.
  • Value v=xWVv=xW_V: what it hands over when chosen.

Keys and values are separate because why a word is relevant differs from what it should contribute.

The recipe

For the position ii being updated, over every position jj it may read:

sj=qi⋅kjdkscoreaj=esj∑mesmweightoi=∑jaj vjoutput\begin{aligned} s_j&=\frac{q_i\cdot k_j}{\sqrt{d_k}} &&\text{score}\\ a_j&=\frac{e^{s_j}}{\sum_m e^{s_m}} &&\text{weight}\\ o_i&=\sum_j a_j\,v_j &&\text{output} \end{aligned}

dkd_k is the length of a query or key vector. The middle line is softmax: exponentiating makes every score positive, and dividing by the total makes the weights sum to 1.

Worked example. cat (position 2) may read positions 1 and 2. Say its scores are s=(0, ln⁡3)s=(0,\ \ln 3) and the values are v1=(2,0)v_1=(2,0), v2=(0,4)v_2=(0,4).

  • Exponentiate: e0=1e^0=1 and eln⁡3=3e^{\ln 3}=3.
  • Divide by the total, 4: a=(1/4, 3/4)a=(1/4,\ 3/4).
  • Mix the values:
o2=14(2,0)+34(0,4)=(0.5, 3)o_2=\tfrac14(2,0)+\tfrac34(0,4)=(0.5,\ 3)

Queries and keys decided how much each position counts. Values decided what got mixed.

The recipe above, drawn for “the cat sat”. Each vector has 2 numbers so it fits on the page; real heads use 64 to 128.

Why divide by √d

If the entries of qq and kk are random with variance 1, then q⋅kq\cdot k adds up dkd_k such products, so its variance is dkd_k and its typical size is dk\sqrt{d_k}. With dk=64d_k=64, scores of ±8 are common. A gap of 8 makes one weight e8≈2981e^8\approx2981 times another, so softmax picks a single position and the others get almost no gradient to learn from. Dividing by 64=8\sqrt{64}=8 brings scores back to about ±1.

Why mask the future

During training the model predicts every position’s next token at once. If position ii could read position i+1i+1, it would just copy the answer. So scores for j>ij>i are set to −∞-\infty, and e−∞=0e^{-\infty}=0 gives them zero weight. This is the causal mask. Generation never has future tokens anyway, so training now matches use.

All positions at once, and many heads

Stack the rows into matrices Q,K,VQ, K, V (one row per position). Then QKTQK^\mathsf T holds every query–key score at once:

Attention⁡(Q,K,V)=softmax⁡ ⁣(QKTdk+M)V\operatorname{Attention}(Q,K,V)=\operatorname{softmax}\!\left(\frac{QK^\mathsf T}{\sqrt{d_k}}+M\right)V

MM is the mask: 0 where j≤ij\le i, −∞-\infty where j>ij>i.

One set of weights can follow one relationship. A word may need several at once: its subject, the previous word, a matching bracket. Multi-head attention runs hh narrower heads side by side, each with its own WQ,WK,WVW_Q, W_K, W_V, then joins their outputs with one more matrix WOW_O. Training decides what each head tracks.

Four hand-designed heads read the same sentence. Pick the asking word: each head spreads its attention differently, and a word can never read the words after it.

The feed-forward network

Attention moved information between positions. The feed-forward network (FFN) then processes each row on its own, with the same weights for every position:

FFN⁡(x)=max⁡(0, xW1+b1) W2+b2\operatorname{FFN}(x)=\max(0,\ xW_1+b_1)\,W_2+b_2
  1. Widen. xW1xW_1 makes the row about 4× wider. Each new entry is a dot product with one column of W1W_1, so it measures how well the row matches that pattern.
  2. Bend. max⁡(0,⋅)\max(0,\cdot), called ReLU, zeroes the negatives, so each feature switches on only when its pattern is present.
  3. Narrow. W2W_2 maps back to the original width. Each active feature adds its own row of W2W_2, scaled by how strongly it fired.

Why the bend matters: without it, two linear maps in a row are just one linear map,

(xW1)W2=x (W1W2),(xW_1)W_2=x\,(W_1W_2),

so the extra width would buy nothing.

Step through widen, bend, narrow on one row. Click any number to see the dot product behind it.

Assemble the block

A block wraps each of the two layers the same way:

x←x+Layer⁡(Norm⁡(x))x \leftarrow x + \operatorname{Layer}\big(\operatorname{Norm}(x)\big)

Normalize the input, compute an update, and add it back. The output has the same shape as the input, so blocks stack: GPT-3 has 96 of them, each with its own weights.

Why add instead of replace

  1. Each layer only has to learn an edit. A layer with nothing to add can output about 0, and xx passes through untouched.
  2. Learning signals get a direct route back. The slope of x+f(x)x+f(x) is 1+f′(x)1+f'(x). The 1 lets the training signal reach the first block even when every f′f' is small; through 96 layers of plain ff, those small factors would multiply toward zero.

Why normalize

The running sum drifts in scale from block to block, and softmax and ReLU behave very differently at different scales. Layer normalization rescales each row before a layer reads it:

LN⁡(x)i=γi xi−μσ+βi\operatorname{LN}(x)_i=\gamma_i\,\frac{x_i-\mu}{\sigma}+\beta_i

μ\mu and σ\sigma are the row’s mean and standard deviation; γ\gamma and β\beta are a learned scale and shift. For x=(1,2,3,6)x=(1,2,3,6), with γ=1\gamma=1 and β=0\beta=0:

  • Mean: μ=12/4=3\mu=12/4=3, so the deviations are (−2,−1,0,3)(-2,-1,0,3).
  • Variance: (4+1+0+9)/4=3.5(4+1+0+9)/4=3.5, so σ≈1.871\sigma\approx1.871.
  • Normalized: (−1.069, −0.535, 0, 1.604)(-1.069,\ -0.535,\ 0,\ 1.604).

(Real implementations add a tiny ε\varepsilon under the square root to avoid dividing by zero.)

Count the parameters

With model width dd, one block holds:

  • Attention: WQ,WK,WV,WOW_Q, W_K, W_V, W_O, each d×dd\times d, so 4d24d^2 in total.
  • FFN: d×4dd\times4d plus 4d×d4d\times d, so 8d28d^2.

That is 12d212d^2 per block. Check it on GPT-3: 96 blocks, d=12,288d=12{,}288:

12×96×12,2882≈1.74×101112\times96\times12{,}288^2\approx1.74\times10^{11}

Add the embedding table (50,257×12,288≈0.650{,}257\times12{,}288\approx0.6 billion) and you get the published 175 billion. Two-thirds of it sits in the FFNs.

Follow the numbers

Here is one complete block, computed. “the cat sat” enters as three rows of four numbers; the weights are invented but fixed, so every number can be checked. Select a token to follow its row, then select any number to see exactly which inputs and weights produced it.

Try the mask: select the first token, open “Change the input”, and change the last word. The first row doesn't move, because it can't see the future. Open it full screen for more room.

From a vector to a word

After the last block, only the last position’s vector, call it hh, is used for the guess. It is the only row that has read the whole input. Each vocabulary token tt has an output vector wtw_t, and its score is a dot product:

zt=h⋅wtz_t=h\cdot w_t

A token whose vector points along hh scores high. Stacking all the wtw_t as columns of one matrix WUW_U computes every score in one multiply, z=hWUz=hW_U. These raw scores are called logits. (Some models reuse the embedding table as WUW_U; that is weight tying.)

Scores become probabilities

The same softmax as in attention:

pt=ezt∑uezup_t=\frac{e^{z_t}}{\sum_u e^{z_u}}

Worked example. For scores (0, ln⁡2, ln⁡3)(0,\ \ln2,\ \ln3), the exponentials are (1,2,3)(1,2,3), which sum to 6, so the probabilities are (1/6, 2/6, 3/6)(1/6,\ 2/6,\ 3/6).

Only gaps matter: pt/pu=ezt−zup_t/p_u=e^{z_t-z_u}, so adding a constant to every score changes nothing.

Temperature

Divide every score by a temperature TT first. That divides every gap by TT. For two tokens whose scores differ by 1, the probability ratio becomes e1/Te^{1/T}:

Temperature TT0.512
Ratio e1/Te^{1/T}7.392.721.65

Low TT stretches the gaps, so the top token takes over. High TT shrinks them, so the choice gets more adventurous. As T→0T\to0 you always pick the top token, which is greedy decoding.

Pick, append, repeat

With 100,000 tokens, the long tail of unlikely ones adds up, and one bad draw can derail the text. So samplers cut the tail first, then rescale what’s left to sum to 1:

  • Top-k: keep the kk most likely tokens.
  • Top-p: keep the most likely tokens until their total reaches pp, so the count adapts to how sure the model is.
All three steps on “the cat sat”, with a toy vocabulary of six words. Drag the temperature, cut the tail with top-k or top-p, then sample.

Append the chosen token and run the model again. That loop is the whole of text generation.

How it learns

Every weight above was given. Training finds them from raw text and one question: how much probability did the model give to the token that actually came next?

Every position is a free example

the cat sat on holds three predictions: the → cat, the cat → sat, the cat sat → on. Thanks to the causal mask, one pass scores all of them at once, and the text is its own answer key. No labels are needed.

The loss: −log of the right answer’s probability

A good model makes real text likely. The probability of a whole text is the product of its per-token probabilities, p1p2⋯pnp_1p_2\cdots p_n. Logs turn that product into a sum (and keep thousands of small numbers from underflowing), and a minus sign makes lower better:

L=−1n∑t=1nln⁡pt\mathcal L=-\frac1n\sum_{t=1}^{n}\ln p_t

Here ptp_t is the probability the model gave the correct token at position tt. This is the cross-entropy loss.

Probability of the right token1.00.50.10.01
Loss −ln⁡p-\ln p00.6932.3034.605

Confidently wrong is punished hardest: as p→0p\to0, the loss grows without limit.

The correction is “predicted minus actual”

Let yy be 1 for the correct token and 0 elsewhere. The slope of the loss with respect to the scores is

∂L∂z=p−y.\frac{\partial\mathcal L}{\partial z}=p-y.

Worked example. The model says p=(0.5, 0.3, 0.2)p=(0.5,\ 0.3,\ 0.2), and the right answer is token 2, so y=(0,1,0)y=(0,1,0):

p−y=(0.5, −0.7, 0.2)p-y=(0.5,\ -0.7,\ 0.2)

Training steps against the slope. The right token’s score goes up by its shortfall (0.7), and each wrong token’s score goes down by the probability it took. Backpropagation (the chain rule, applied block by block) carries this signal back to every weight ww, which moves a small step η\eta against its own slope:

w←w−η ∂L∂ww\leftarrow w-\eta\,\frac{\partial\mathcal L}{\partial w}

Repeat over trillions of tokens and the weights end up encoding grammar, facts, and everything else that helps predict the next word.

Generating fast

Each new token needs keys and values for every earlier position. But those never change: position 3’s key depends only on tokens 1–3. So models store them in a KV cache and compute only the new token’s row. A step then costs tt query–key pairs instead of recomputing all t(t+1)/2t(t+1)/2.

Generate “on the mat” after “the cat sat”. Without a cache every step redoes the whole triangle; with one, each step adds a single row.

The whole thing in code

A complete small GPT in PyTorch

Every piece above, in about 40 lines: embeddings plus positions, pre-norm blocks with causal multi-head attention and a ReLU FFN, a final norm, and the unembedding.

import torch
import torch.nn as nn
import torch.nn.functional as F

class Block(nn.Module):
    def __init__(self, d, heads):
        super().__init__()
        self.norm1, self.norm2 = nn.LayerNorm(d), nn.LayerNorm(d)
        self.attn = nn.MultiheadAttention(d, heads, batch_first=True)
        self.ffn = nn.Sequential(nn.Linear(d, 4 * d), nn.ReLU(), nn.Linear(4 * d, d))

    def forward(self, x):                              # x: (batch, n, d)
        n = x.size(1)
        future = torch.triu(torch.ones(n, n, dtype=torch.bool, device=x.device), 1)
        h = self.norm1(x)
        x = x + self.attn(h, h, h, attn_mask=future, need_weights=False)[0]
        x = x + self.ffn(self.norm2(x))
        return x

class TinyGPT(nn.Module):
    def __init__(self, vocab, d=64, heads=4, layers=4, max_len=256):
        super().__init__()
        self.tok = nn.Embedding(vocab, d)
        self.pos = nn.Embedding(max_len, d)
        self.blocks = nn.ModuleList(Block(d, heads) for _ in range(layers))
        self.norm = nn.LayerNorm(d)
        self.unembed = nn.Linear(d, vocab, bias=False)

    def forward(self, ids):                            # ids: (batch, n)
        positions = torch.arange(ids.size(1), device=ids.device)
        x = self.tok(ids) + self.pos(positions)
        for block in self.blocks:
            x = block(x)
        return self.unembed(self.norm(x))              # logits: (batch, n, vocab)

def train_step(model, optimizer, tokens):              # tokens: (batch, n + 1)
    logits = model(tokens[:, :-1])                     # predict from each prefix
    loss = F.cross_entropy(logits.flatten(0, 1), tokens[:, 1:].flatten())
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    return loss.item()

@torch.no_grad()
def generate(model, ids, steps, temperature=1.0):
    for _ in range(steps):
        context = ids[:, -model.pos.num_embeddings:]  # at most max_len tokens
        logits = model(context)[:, -1] / temperature   # last position only
        next_id = torch.multinomial(F.softmax(logits, dim=-1), 1)
        ids = torch.cat([ids, next_id], dim=1)          # append and repeat
    return ids
Further reading
  1. Vaswani et al. “Attention Is All You Need.” 2017. The original transformer.
  2. Jurafsky and Martin. Speech and Language Processing, Chapter 8: “Transformers.” A careful textbook treatment.
  3. Radford et al. “Language Models are Unsupervised Multitask Learners” (GPT-2). 2019. The pre-norm decoder used here.
  4. Brown et al. “Language Models are Few-Shot Learners” (GPT-3). 2020. The 175-billion-parameter shape used in the parameter count.
  5. Su et al. “RoFormer: Rotary Position Embedding.” 2021. RoPE.
  6. Elhage et al. “A Mathematical Framework for Transformer Circuits.” 2021. Reading the model as a residual stream.