Skip to content

Attention and the Transformer

Chapter 36 12 code blocks 4594 words

The scene: Chapter 35 left us with a problem

In the last chapter we measured it: in a sequence of length T=60, the gradient reaching the first step is billions of times smaller than at the last step. That's why neither the RNN nor the LSTM reliably reached the conclusion "the first element matters".

The root of the problem is the path length. In an RNN, getting from step 1 to step 60 takes 59 multiplications. Every multiplication shrinks the signal.

The idea of the fix is very simple: don't walk. Look at every step directly.

python27 lines
import os
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

torch.set_num_threads(4)

diagramma(
    {
        "step 1": (0.2, 0.2, 1.3, 0.7, "manba"),
        "...": (1.9, 0.2, 1.3, 0.7, "manba"),
        "step 60": (3.6, 0.2, 1.3, 0.7, "manba"),
        "RNN: walks through\\n59 steps": (0.2, 1.5, 4.7, 0.8, "xizmat"),
        "Attention: a DIRECT\\npath to every step": (0.2, 2.7, 4.7, 0.8, "qayta"),
    },
    [("step 1", "RNN: walks through\\n59 steps"),
     ("step 60", "RNN: walks through\\n59 steps"),
     ("RNN: walks through\\n59 steps", "Attention: a DIRECT\\npath to every step")],
    nom="yol_uzunligi.png",
    izoh="In an RNN information travels along a chain and weakens on the way. "
         "In attention the distance between any two positions is a single step.",
)

In an RNN information travels along a chain and weakens on the way. In attention the distance between any two positions is a single step.

In an RNN information travels along a chain and weakens on the way. In attention the distance between any two positions is a single step.

Attention by hand: three matrices

The whole essence of attention is in one formula. And every operation in that formula was covered in Chapter 12.

Every position builds three vectors:

  • Q (query): "what am I looking for?"
  • K (key): "what do I have?"
  • V (value): "if you take me, this is what you get"
python32 lines
torch.manual_seed(0)
T_small, d = 4, 8
h = torch.randn(1, T_small, d)

Wq, Wk, Wv = (nn.Linear(d, d, bias=False) for _ in range(3))
Q, K, V = Wq(h), Wk(h), Wv(h)
print(f"Input h: {tuple(h.shape)}   -> {T_small} positions, each a {d}-dimensional vector")
print(f"Q, K, V: {tuple(Q.shape)}")
print()

score = Q @ K.transpose(1, 2)
print("Step 1: the dot product of every Q with every K (a similarity score)")
print(f"  score shape: {tuple(score.shape)}  -> every position scores every position")
print(np.round(score[0].detach().numpy(), 2))
print()

score_scaled = score / d ** 0.5
print(f"Step 2: divide by sqrt(d) (d={d}, sqrt={d**0.5:.3f})")
print("  Why: when d is large the scores are large too, and softmax sticks to 0 and 1.")
print()

weight = torch.softmax(score_scaled, dim=-1)
print("Step 3: softmax turns the scores into weights")
print(np.round(weight[0].detach().numpy(), 3))
print(f"  each row sums to: {weight[0].sum(dim=-1).detach().numpy().round(4)}")
print()

output = weight @ V
print(f"Step 4: the weighted average of V -> {tuple(output.shape)}")
print()
print("All in one line:")
print("  softmax(Q @ K.T / sqrt(d)) @ V")
Output
Input h: (1, 4, 8)   -> 4 positions, each a 8-dimensional vector
Q, K, V: (1, 4, 8)

Step 1: the dot product of every Q with every K (a similarity score)
  score shape: (1, 4, 4)  -> every position scores every position
[[ 0.47 -0.97 -0.27 -1.25]
 [-0.46 -0.39 -0.89 -0.09]
 [-0.63 -0.73 -0.3  -0.52]
 [-0.41 -0.08 -0.66 -0.24]]

Step 2: divide by sqrt(d) (d=8, sqrt=2.828)
  Why: when d is large the scores are large too, and softmax sticks to 0 and 1.

Step 3: softmax turns the scores into weights
[[0.343 0.207 0.264 0.187]
 [0.249 0.255 0.213 0.283]
 [0.242 0.234 0.272 0.252]
 [0.244 0.274 0.223 0.259]]
  each row sums to: [1. 1. 1. 1.]

Step 4: the weighted average of V -> (1, 4, 8)

All in one line:
  softmax(Q @ K.T / sqrt(d)) @ V

Attention is a learned average

If you had to put the formula in one sentence: attention is an average whose weights are computed from the data.

In an ordinary average every element gets an equal weight. In attention the weights come from softmax(QK/sqrt(d)), so the model decides every time: "in this situation, which position should I look at?"

That's why it's called "attention".

We measure the gradient flow

In Chapter 35 we measured the gradient for the RNN and the LSTM. Now we put attention through the same measurement.

python47 lines
def task(n, T, seed=0):
    """Only the FIRST element decides the answer. The rest is noise."""
    rr = np.random.default_rng(seed)
    Xs = rr.normal(0, 1, (n, T, 1)).astype(np.float32)
    return torch.tensor(Xs), torch.tensor((Xs[:, 0, 0] > 0).astype(np.int64))

class SeqModel(nn.Module):
    def __init__(self, kind, h=32):
        super().__init__()
        self.rnn = nn.RNN(1, h, batch_first=True) if kind == "rnn" else nn.LSTM(1, h, batch_first=True)
        self.fc = nn.Linear(h, 2)
    def forward(self, x):
        o, _ = self.rnn(x)
        return self.fc(o[:, -1])

class Attention(nn.Module):
    def __init__(self, T, d=32, position=True):
        super().__init__()
        self.inp = nn.Linear(1, d)
        self.pos = nn.Parameter(torch.randn(1, T, d) * 0.02) if position else None
        self.q, self.k, self.v = (nn.Linear(d, d) for _ in range(3))
        self.fc = nn.Linear(d, 2)
        self.weight = None
    def forward(self, x):
        hh = self.inp(x)
        if self.pos is not None:
            hh = hh + self.pos
        Qx, Kx, Vx = self.q(hh), self.k(hh), self.v(hh)
        w = torch.softmax(Qx @ Kx.transpose(1, 2) / Kx.shape[-1] ** 0.5, dim=-1)
        self.weight = w.detach()
        return self.fc((w @ Vx).mean(1))

T = 60
print(f"The gradient reaching the input's first and last steps (T={T}):")
print()
grad_table = []
for name, make in [("RNN", lambda: SeqModel("rnn")), ("LSTM", lambda: SeqModel("lstm")),
                   ("attention", lambda: Attention(T))]:
    torch.manual_seed(0)
    Xg, yg = task(256, T, 1)
    Xg.requires_grad_(True)
    m = make()
    nn.CrossEntropyLoss()(m(Xg), yg).backward()
    g = Xg.grad.abs().mean(dim=(0, 2))
    grad_table.append({"model": name, "step 0": f"{g[0]:.3e}", "step 59": f"{g[59]:.3e}",
                       "step59 / step0": f"{g[59] / g[0]:.2e}"})
show(pd.DataFrame(grad_table))
Output
The gradient reaching the input's first and last steps (T=60):
modelstep 0step 59step59 / step0
RNN2.427e-171.129e-054.65e+11
LSTM2.503e-172.982e-051.19e+12
attention6.268e-066.604e-061.05e+00

Look at the ratio column. For the RNN and the LSTM it runs into billions, which means the first step gets almost no signal. For attention the ratio is close to one: the gradient reaches every position equally.

The reason is structural: in attention there's one matrix multiplication between step 1 and step 60, not 59.

A test: the same memory task

We repeat the task from Chapter 35, but add attention to the line-up. The conditions are the same: 3 seeds, the same training budget.

python31 lines
def train(make, T_, seed, epochs=60, n=2000, lr=0.005):
    torch.manual_seed(seed)
    Xtr, ytr = task(n, T_, 1)
    Xte, yte = task(600, T_, 2)
    m = make()
    opt = torch.optim.Adam(m.parameters(), lr=lr)
    crit = nn.CrossEntropyLoss()
    dl = DataLoader(TensorDataset(Xtr, ytr), batch_size=64, shuffle=True,
                    generator=torch.Generator().manual_seed(seed))
    for _ in range(epochs):
        m.train()
        for xb, yb in dl:
            opt.zero_grad(); crit(m(xb), yb).backward(); opt.step()
    m.eval()
    with torch.no_grad():
        return float((m(Xte).argmax(1) == yte).float().mean()), m

print("A random answer = 0.50. Every model with 3 seeds.")
test = []
for name, make in [
    ("RNN", lambda: SeqModel("rnn")),
    ("LSTM", lambda: SeqModel("lstm")),
    ("attention (+ position)", lambda: Attention(T)),
    ("attention (NO position)", lambda: Attention(T, position=False)),
]:
    acc = [train(make, T, s)[0] for s in (0, 1, 2)]
    test.append({"model": name, "seeds": str([round(a, 3) for a in acc]),
                 "mean": round(float(np.mean(acc)), 4),
                 "worst": round(min(acc), 4),
                 "parameters": sum(p.numel() for p in make().parameters())})
show(pd.DataFrame(test))
Output
A random answer = 0.50. Every model with 3 seeds.
modelseedsmeanworstparameters
RNN[0.847, 0.98, 0.98]0.9360.8471 186
LSTM[0.542, 0.97, 0.517]0.6760.5174 546
attention (+ position)[0.998, 0.995, 0.998]0.9970.9955 218
attention (NO position)[0.543, 0.56, 0.562]0.5550.5433 298

The result is clear: attention solves the task on every seed. The LSTM stayed unstable, as we saw in Chapter 35.

But look at the last row of the table: it matters most.

Positional encoding: attention does NOT KNOW the order

The attention (NO position) result is at the level of a random answer. That's not chance.

python14 lines
torch.manual_seed(0)
m_pos = Attention(T, position=False)
Xs, _ = task(8, T, 5)
with torch.no_grad():
    orig = m_pos(Xs)
    shuffled = m_pos(Xs[:, torch.randperm(T)])     # we SHUFFLE the positions

print("Attention without positional encoding: let's shuffle the input:")
print(f"  original output[0] : {orig[0].numpy().round(5)}")
print(f"  shuffled output[0] : {shuffled[0].numpy().round(5)}")
print(f"  maximum difference : {float((orig - shuffled).abs().max()):.2e}")
print()
print("The output didn't change. So to the model a sequence is an UNORDERED SET.")
print("It has no notion of 'the first element', so it can't solve the task.")
Output
Attention without positional encoding: let's shuffle the input:
  original output[0] : [-0.18954  0.12116]
  shuffled output[0] : [-0.18954  0.12116]
  maximum difference : 2.98e-08

The output didn't change. So to the model a sequence is an UNORDERED SET.
It has no notion of 'the first element', so it can't solve the task.

Attention doesn't see order by itself: position has to be added

Look at the formula softmax(QK/sqrt(d)) @ V: the position index takes part nowhere in it. Shuffle the inputs and the outputs just get shuffled too, but their content doesn't change.

The measurement above proved it: on the shuffled input, the output stayed exactly the same.

That's why every transformer adds information about position by hand:

  • a learned position vector (what we used: nn.Parameter)
  • sine/cosine encoding (in the original "Attention Is All You Need" paper)
  • RoPE (widespread in today's LLMs)

Forgetting it is the most common mistake when writing a transformer from scratch. The model trains, the loss goes down, but everything that depends on order breaks.

SHOWING what the model looked at

Attention has one more practical advantage: you can open up the weights and look. In Chapter 26 we explained a model with SHAP; here the explanation is ready-made inside the model.

python13 lines
accuracy, model = train(lambda: Attention(T), T, 0)
Xv, _ = task(200, T, 2)
with torch.no_grad():
    model(Xv)
w = model.weight.mean(dim=(0, 1)).numpy()

print(f"Accuracy of the trained attention model: {accuracy:.4f}")
print()
print(f"Average attention on step 0           : {w[0]:.4f}")
print(f"Average over the other 59 steps       : {w[1:].mean():.6f}")
print(f"The step that got the most attention  : {int(w.argmax())}")
print()
print("The model found it by itself: the answer is at the FIRST step. We gave it no hint.")
Output
Accuracy of the trained attention model: 0.9983

Average attention on step 0           : 0.9975
Average over the other 59 steps       : 0.000042
The step that got the most attention  : 0

The model found it by itself: the answer is at the FIRST step. We gave it no hint.
python13 lines
figure, (b1, b2) = plt.subplots(1, 2, figsize=(14, 4.2),
                                gridspec_kw={"width_ratios": [1.15, 1]})
b1.bar(np.arange(T), w, color="#1a73e8")
b1.set_title("Average attention given to each step")
b1.set_xlabel("sequence step"); b1.set_ylabel("weight")
image = b2.imshow(model.weight[0].numpy(), cmap="viridis", aspect="auto")
b2.set_title("The attention matrix (one sample)")
b2.set_xlabel("step being looked at"); b2.set_ylabel("query step")
plt.colorbar(image, ax=b2, fraction=0.046)
plt.tight_layout()
fig("attention_ogirligi.png",
    caption="Left: all the attention is concentrated on step 0. Right: the attention matrix's "
            "left column is bright: every position looks at step 0.")

Left: all the attention is concentrated on step 0. Right: the attention matrix's left column is bright: every position looks at step 0.

Left: all the attention is concentrated on step 0. Right: the attention matrix's left column is bright: every position looks at step 0.

The cost: O(T squared)

Attention isn't free. It compares every position with every position, which means it builds a T x T matrix. Let's measure that.

python15 lines
print(f"{'T':>6} {'RNN time (ms)':>16} {'attention (ms)':>16} {'attn matrix size':>22}")
print("-" * 64)
for Tx in [30, 60, 120, 240]:
    Xb, _ = task(64, Tx, 3)
    r_m, a_m = SeqModel("rnn"), Attention(Tx)
    with torch.no_grad():
        r_m(Xb); a_m(Xb)                                   # warm-up
        t0 = time.perf_counter(); [r_m(Xb) for _ in range(10)]
        t_rnn = (time.perf_counter() - t0) * 100
        t0 = time.perf_counter(); [a_m(Xb) for _ in range(10)]
        t_attn = (time.perf_counter() - t0) * 100
    print(f"{Tx:>6} {t_rnn:>16.2f} {t_attn:>16.2f} {f'{Tx}x{Tx} = {Tx*Tx}':>22}")
print()
print("The matrix size column: when T doubles, it grows EXACTLY fourfold.")
print("The time columns say something else: read the note below.")
Output
     T    RNN time (ms)   attention (ms)       attn matrix size
----------------------------------------------------------------
    30             0.32             0.17            30x30 = 900
    60             0.58             0.30           60x60 = 3600
   120             1.08             0.68        120x120 = 14400
   240             2.09             3.88        240x240 = 57600

The matrix size column: when T doubles, it grows EXACTLY fourfold.
The time columns say something else: read the note below.

The measurement half-rejected my assumption

I put this table here for the conclusion "attention is expensive". The result came out differently: at small T, attention runs faster than the RNN.

The reason is parallelism. An RNN has to go through its T steps one after another: 240 steps means 240 operations waiting in a queue. Attention does all of it in one big matrix multiplication, and matrix multiplication is extremely well optimised.

The quadratic growth only takes over when T is large. In the table the crossover point appears between 120 and 240.

So the right conclusion has two parts:

  1. On speed attention wins: that's why transformers replaced RNNs. You can train them in parallel on a GPU; you can't do that with an RNN.
  2. On memory and long contexts the quadratic growth is a real barrier: you have to store the T x T matrix. For a 100 000-token context that's 10 billion elements.

That second reason is why methods like FlashAttention, sliding-window attention and MQA appeared: they solve exactly the memory problem, not speed.

The general lesson: the word "expensive" means nothing without a measurement. Expensive in what: time, memory, or parameters?

The Transformer block

Attention is one brick. A Transformer block adds four things around it.

python13 lines
show(pd.DataFrame([
    {"Part": "1. Multi-head attention",
     "Job": "Several attentions in parallel: each looks at a different relationship"},
    {"Part": "2. A residual connection (x + attn(x))",
     "Job": "Gives the gradient a direct path: required for a deep network"},
    {"Part": "3. LayerNorm",
     "Job": "Stabilises each layer's output"},
    {"Part": "4. Feed-forward (a 2-layer MLP)",
     "Job": "Processes the information attention gathered"},
]))
print()
print("This block is stacked N times. GPT, BERT, Llama: all variants of this pattern.")
print("The differences: the number of blocks, the size, the kind of positional encoding, and the attention mask.")
Output
This block is stacked N times. GPT, BERT, Llama: all variants of this pattern.
The differences: the number of blocks, the size, the kind of positional encoding, and the attention mask.
PartJob
1. Multi-head attentionSeveral attentions in parallel: each looks at a different relationship
2. A residual connection (x + attn(x))Gives the gradient a direct path: required for a deep network
3. LayerNormStabilises each layer's output
4. Feed-forward (a 2-layer MLP)Processes the information attention gathered
python19 lines
class TransformerBlock(nn.Module):
    """Minimal but complete: attention + residual + norm + feed-forward."""
    def __init__(self, d=32, heads=4):
        super().__init__()
        self.attn = nn.MultiheadAttention(d, heads, batch_first=True)
        self.norm1, self.norm2 = nn.LayerNorm(d), nn.LayerNorm(d)
        self.ff = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))
    def forward(self, x):
        a, _ = self.attn(x, x, x)
        x = self.norm1(x + a)                 # residual + norm
        return self.norm2(x + self.ff(x))     # residual + norm

block = TransformerBlock()
sample = torch.randn(2, 10, 32)
print(f"Input : {tuple(sample.shape)}")
print(f"Output: {tuple(block(sample).shape)}   (the shape is kept, which is why blocks can be stacked)")
print(f"Parameters: {sum(p.numel() for p in block.parameters())}")
print()
print("nn.MultiheadAttention is a ready-made, faster version of the formula we wrote by hand.")
Output
Input : (2, 10, 32)
Output: (2, 10, 32)   (the shape is kept, which is why blocks can be stacked)
Parameters: 12704

nn.MultiheadAttention is a ready-made, faster version of the formula we wrote by hand.

What you can do with it

python10 lines
show(pd.DataFrame([
    {"Task": "Text classification", "Approach": "A pretrained model + the last layer (fine-tuning)"},
    {"Task": "Question-answering over your own documents", "Approach": "RAG: retrieval + an LLM"},
    {"Task": "Text in your own style", "Approach": "LoRA fine-tuning"},
    {"Task": "Text from images (OCR)", "Approach": "A vision transformer + a decoder"},
    {"Task": "A time series", "Approach": "Chapter 35 first: differencing and classic methods"},
]))
print()
print("Important: in none of these tasks do you train a transformer FROM SCRATCH.")
print("You take a pretrained model and adapt it. How to do that is in Chapter 39 (LoRA, QLoRA).")
Output
Important: in none of these tasks do you train a transformer FROM SCRATCH.
You take a pretrained model and adapt it. How to do that is in Chapter 39 (LoRA, QLoRA).
TaskApproach
Text classificationA pretrained model + the last layer (fine-tuning)
Question-answering over your own documentsRAG: retrieval + an LLM
Text in your own styleLoRA fine-tuning
Text from images (OCR)A vision transformer + a decoder
A time seriesChapter 35 first: differencing and classic methods

Training from scratch isn't your job

In the measurements above, our attention model solved an artificial task of length 60. A real language model needs billions of tokens and thousands of GPU-hours.

The practical work is adapting a pretrained model. That's why the next part of this book (Chapters 37-41) builds exactly that chain: collecting data, building a dataset, fine-tuning, saving, and serving.

And you need the theory for this: why there's a context limit, why a long prompt is expensive, why position matters, why a model sometimes "forgets" an instruction at the start. The answers to those questions are in this chapter's formula.

What we learned today

python12 lines
show(pd.DataFrame([
    {"Concept": "The attention formula", "In one sentence": "softmax(Q @ K.T / sqrt(d)) @ V"},
    {"Concept": "Q, K, V", "In one sentence": "Query, key, value: all three are linear projections"},
    {"Concept": "Why sqrt(d)", "In one sentence": "Keeps the softmax from saturating"},
    {"Concept": "Path length", "In one sentence": "One step between any positions, not 59"},
    {"Concept": "Gradient flow", "In one sentence": "Measured: the ratio is close to one, billions for the RNN"},
    {"Concept": "Positional encoding", "In one sentence": "Measured: without it the model can't see order"},
    {"Concept": "Explanation", "In one sentence": "The weights show where the model looked"},
    {"Concept": "O(T squared)", "In one sentence": "The matrix grows 4x: the barrier is MEMORY, not speed"},
    {"Concept": "Parallelism", "In one sentence": "Measured: at small T attention is faster than an RNN"},
    {"Concept": "The Transformer block", "In one sentence": "Attention + residual + norm + feed-forward"},
]))
ConceptIn one sentence
The attention formulasoftmax(Q @ K.T / sqrt(d)) @ V
Q, K, VQuery, key, value: all three are linear projections
Why sqrt(d)Keeps the softmax from saturating
Path lengthOne step between any positions, not 59
Gradient flowMeasured: the ratio is close to one, billions for the RNN
Positional encodingMeasured: without it the model can't see order
ExplanationThe weights show where the model looked
O(T squared)The matrix grows 4x: the barrier is MEMORY, not speed
ParallelismMeasured: at small T attention is faster than an RNN
The Transformer blockAttention + residual + norm + feed-forward

That completes Part 7. The next part is the book's most practical chain: from collecting your own data all the way to putting a model into a working service.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents