Deep Learning · Module 16 of 26 · 8 concepts · about 19 min

Attention

A learned, content-dependent way to choose which information matters now.

At work: The idea behind today's translation, search and chat systems: focusing on the parts of the input that matter.

Queries, keys, values and attention weights

What it is

Attention is a learned retrieval operation. A query vector is compared with key vectors to produce relevance scores; softmax turns those scores into non-negative weights that sum to one; the output is the weighted combination of value vectors. A query asks what is relevant now, keys say what each candidate offers, and values contain the information to retrieve. The query, key, and value projections are learned, so the model can discover several useful notions of relevance rather than relying on a fixed distance rule.

Why it matters

Unlike a fixed recurrent state, attention can directly connect distant items, and its weights change with the current query. This creates short information paths between related positions, but it also means the cost and behaviour of the model depend on sequence length, masking, and the learned representations rather than on word distance alone.

In the real world: How a translation model decides which source words matter for each word it writes.

Visual example

Interactive attention routing

Select a query token to inspect its weights, strongest connection, and the paths used to retrieve context.

Choose a query token

“The” attends most to The · 56%

TheTheanimalwastired

Attention matrix · rows are queries

TheanimalwastiredTheanimalwastired

Why this matters

Attention is a content-dependent routing operation. Changing the query changes the whole distribution over the same candidate values.

  1. 1Choose a queryEach row of the matrix is one token asking what to retrieve.
  2. 2Read its rowEvery row sums to 100%; darker cells carry more value into the output.
  3. 3Follow pathsThe paths show that same row as weighted retrieval, not as a fixed connection.
import torch
import torch.nn.functional as F

q = torch.randn(1, 64)            # one query
k = torch.randn(6, 64)            # six candidates
v = torch.randn(6, 64)

scores = q @ k.T / 64 ** 0.5      # (1, 6) compatibilities
weights = F.softmax(scores, dim=-1)
out = weights @ v                 # (1, 64) — a weighted average of values
weights.sum()                     # tensor(1.)

Reading attention weights as a complete explanation. They describe one routing operation, not necessarily which input caused the final model decision.

Practise it: Scaled Dot-Product Attention

The query/key/value framing in plain language

What it is

Before any matrix appears, the three roles are worth stating as three questions. A query is 'what am I looking for right now' — produced from the position doing the looking. A key is 'what do I have on offer' — produced from each candidate position, and compared against the query to decide relevance. A value is 'what gets handed over if I am chosen' — also produced from each candidate, but serving a different purpose, which is why keys and values are separate projections of the same vector rather than the same tensor. The analogy is a lookup that returns a blend: instead of matching one key exactly, every key gets a score, and the answer is the score-weighted mixture of all the values.

Why it matters

Keeping the three roles distinct is what makes every later variant readable. Cross-attention is 'queries from the decoder, keys and values from the encoder'; self-attention is 'all three from the same sequence'; a key/value cache is 'the keys and values are reusable across steps but the query is new each step'. Each is one sentence once the roles are clear, and impenetrable otherwise.

In the real world: Like a search engine: a query is matched against keys to pull back the most relevant values.

Visual example

Three roles

One vector per position, three different projections of it, three different jobs.

what each projection is for
rolecomes fromthe question it answers
query qthe position doing the lookingwhat am I looking for?
key kevery candidate positionwhat do I have on offer?
value vevery candidate positionwhat gets handed over?

Common misconception

Wrong idea: attention picks the single best-matching position.

Correct idea: it returns a weighted blend of every value, with the weights summing to 1. A confident distribution approximates a single pick; a flat one is an average.

import torch.nn as nn

d_model, d_head = 512, 64
W_q = nn.Linear(d_model, d_head, bias=False)   # what am I looking for
W_k = nn.Linear(d_model, d_head, bias=False)   # what do I offer
W_v = nn.Linear(d_model, d_head, bias=False)   # what do I hand over

q = W_q(decoder_state)     # (batch, 1, 64)
k, v = W_k(encoder_out), W_v(encoder_out)      # (batch, src_len, 64)

Using the same projection for keys and values, or skipping the projections and attending on raw embeddings. The separate W_k and W_v are what let the model score on one property and retrieve a different one — 'find the subject' and 'return its number agreement' are not the same vector.

Why attention was introduced: the encoder-decoder bottleneck

What it is

A vanilla sequence-to-sequence RNN encodes the source sentence into its final hidden state and hands that one fixed-size vector to the decoder, which must generate the entire output from it. For a five-word sentence this is workable; for a forty-word one, a single 512-dimensional vector has to carry every word, its order and its structure, and translation quality falls sharply with source length. Attention removed the constraint: keep *all* the encoder states, and let each decoder step compute a query and take a weighted average over them. The decoder can then look at the part of the source it currently needs, and the alignment between source and target positions emerges from training rather than being engineered.

Why it matters

This is the origin story, and it explains the shape of the mechanism — attention is a fix for a compression bottleneck, which is why its output is an average over retained states rather than a transformation of a summary. It also explains why attention was a decoder-to-encoder operation for years before anyone applied it within a single sequence; module 17's self-attention is the later, and more radical, step.

In the real world: Early translation models squeezed a whole sentence into one vector and lost detail; attention fixed long sentences.

Visual example

The bottleneck, removed

The decoder stops reading a compression of the source and starts reading the source.

vanilla seq2seq
what the decoder seessize
h_n, the final encoder state512 numbers, total
every source word, compressed into itno per-position access
one summary vector -> a weighted average over all states
with attention
what the decoder seessize
all encoder states, keptsrc_len × 512
a fresh weighted average per stepchosen by the current query

Why this matters

Attention is easiest to understand as a fix for a specific, measurable failure rather than as an abstract mechanism.

Translation quality for a vanilla seq2seq degrades steeply past ~20 source words; with attention it stays roughly flat. The bottleneck was the cause.

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

encoder_out, _ = encoder(src)        # (batch, src_len, 512) — keep them ALL

h = decoder_init
for step in range(max_len):
    q = W_q(h).unsqueeze(1)                              # (batch, 1, 64)
    scores = q @ W_k(encoder_out).transpose(1, 2) / 64 ** 0.5
    alpha = F.softmax(scores, dim=-1)                    # (batch, 1, src_len)
    context = alpha @ W_v(encoder_out)                   # (batch, 1, 64)
    h = decoder_cell(torch.cat([embed(prev), context.squeeze(1)], -1), h)

Keeping only the encoder's final state and adding attention over it. There is nothing to attend to — attention needs the sequence of encoder outputs, which means returning encoder_out rather than just h_n.

Practise it: Scaled Dot-Product Attention

Scaled dot-product attention, term by term

What it is

Attention(Q, K, V) = softmax(QKᵀ / √d_k) V, read right to left in four steps. QKᵀ is every query against every key, giving an (n_q × n_k) matrix of raw compatibility scores — a dot product, so it is large when two vectors point the same way. Dividing by √d_k rescales those scores. softmax over the last axis turns each row into a probability distribution across the candidate positions. Multiplying by V replaces each row with the corresponding weighted average of the value vectors. The scaling exists because a dot product of two d_k-dimensional random vectors has variance proportional to d_k, so at d_k = 64 the raw scores are roughly 8× larger than at d_k = 1; feeding those into softmax saturates it into a near one-hot distribution whose gradient is almost zero, and the model cannot learn to adjust its attention. Dividing by √d_k puts the scores back in the range where softmax has usable gradients.

Why it matters

The √d_k is the one term in the formula that looks arbitrary and is not: it is a variance correction, and without it a wide-head model does not train. Knowing why also generalises — it is the same saturation argument as module 3's warning about sigmoid, and it is why attention logits are sometimes clamped or normalised further in very large models.

In the real world: The core calculation inside every large language model a business uses today.

Visual example

Why √d_k

Unscaled dot products grow with dimension and saturate softmax. The scaling keeps the maximum weight in a trainable range.

unscaled QKᵀscaled by √d_k
2040608010012000.20.40.60.81head dimension d_klargest softmax weight in a rowsaturated: softmax gradient ≈ 0

The variance argument

q·k = Σ over d_k terms of independent products

Var(q·k) ∝ d_k, so sd(q·k) ∝ √d_k

at d_k = 64 the scores are ~8× wider than at d_k = 1

divide by √d_k to restore unit scale

The red curve is not 'more confident attention' — it is a layer whose weights barely move during training, because a saturated softmax passes almost no gradient back to the scores.

import torch
import torch.nn.functional as F

d_k = 64
q, k, v = torch.randn(1, 8, d_k), torch.randn(1, 8, d_k), torch.randn(1, 8, d_k)

raw = q @ k.transpose(-2, -1)            # std ~ sqrt(d_k) = 8
scaled = raw / d_k ** 0.5                # std ~ 1

F.softmax(raw, dim=-1).max()             # tensor(0.97) — saturated, no gradient
F.softmax(scaled, dim=-1).max()          # tensor(0.31) — usable
out = F.softmax(scaled, dim=-1) @ v      # (1, 8, 64)

Scaling by d_k instead of √d_k. The scores are then far too small, every attention row is nearly uniform, and the layer degenerates into an unweighted average of the values — which trains without error and learns nothing about relevance.

Practise it: Scaled Dot-Product Attention

Attention weights as a distribution over positions

What it is

The softmax is taken over the *candidate* axis, so every query's weights are non-negative and sum to exactly 1 — the same construction as module 3's softmax over classes, with positions in place of classes. Two properties follow. The output is a convex combination of the value vectors, so it lies inside their span and cannot be larger in magnitude than the largest of them, which is what makes attention numerically stable. And the distribution's *shape* is meaningful: a peaked row means the query found one clearly relevant position, while a near-uniform row means it found nothing distinguishing and is returning an average. Its entropy is a cheap, real diagnostic.

Why it matters

It is the fact to check first when attention is not behaving. Rows that sum to something other than 1 mean the softmax is on the wrong axis — a genuinely common bug, since dim=-1 and dim=-2 both run without error and produce plausible-looking matrices. Uniformly flat rows across all heads and layers mean the scores carry no signal, which points at the scaling, the initialization, or a mask that removed everything.

In the real world: Showing which words in a customer review led the model to call it negative.

import torch
import torch.nn.functional as F

scores = torch.randn(2, 5, 5)
weights = F.softmax(scores, dim=-1)        # over KEYS — the candidate axis

weights.sum(dim=-1)                        # all ones: the check worth making
entropy = -(weights * weights.clamp_min(1e-9).log()).sum(-1)
entropy.mean(), torch.tensor(5.0).log()    # compare against ln(n) = uniform
# entropy near ln(5) everywhere -> the scores carry no signal

Softmaxing over the query axis (dim=-2). Each *column* then sums to 1 instead of each row, so a position's output is no longer a weighted average of values — it is a scaled mixture that changes when unrelated queries are added to the batch.

Practise it: Scaled Dot-Product Attention

Multi-head attention

What it is

One attention operation produces one distribution per query, so it can express one notion of relevance at a time. Multi-head attention splits the model dimension into h smaller subspaces — 8 heads of 64 dimensions rather than one of 512 — runs the whole scaled dot-product operation independently in each, concatenates the results, and applies one output projection. The cost is essentially unchanged because the head dimension shrinks proportionally, and the gain is that different heads can settle on different relationships: one tracking syntactic dependencies, another matching a pronoun to its antecedent, another simply attending to the previous token.

Why it matters

Without heads, a single softmax has to serve every kind of relationship at once, and the averaging blurs them. Heads are also the practical knob in a Transformer's configuration alongside depth and width (module 18), and the reason d_model must be divisible by n_heads — a constraint that otherwise looks like an arbitrary API requirement.

In the real world: Letting a model track several things at once, like grammar and meaning, in a support ticket.

Visual example

Deep learning

The same operation, run in parallel in several smaller subspaces, then recombined.

Multi-head attention

  1. input(batch, 20, 512)
  2. project and split8 heads × (batch, 20, 64)
  3. scaled dot-product per head8 independent 20×20 distributions
  4. concatenate(batch, 20, 512)again
  5. output projection W_o(batch, 20, 512)heads mixed

d_model must divide evenly by n_heads, because each head takes d_model / n_heads dimensions.

import torch
import torch.nn as nn

mha = nn.MultiheadAttention(embed_dim=512, num_heads=8, batch_first=True)
x = torch.randn(4, 20, 512)
out, weights = mha(x, x, x, average_attn_weights=False)
out.shape       # (4, 20, 512)
weights.shape   # (4, 8, 20, 20) — one 20x20 matrix PER HEAD

512 % 8         # 0 — required: each head gets 512 // 8 = 64 dimensions

Averaging the heads' attention matrices before inspecting them. nn.MultiheadAttention does this by default, and the average of eight sharply different patterns is a smear that shows nothing. Pass average_attn_weights=False.

Practise it: Scaled Dot-Product Attention

Reading an attention row

What it is

One row of an attention matrix is one query's distribution over the candidates, and it is the most interpretable object in a Transformer — with real limits. A row is read as: which positions carry weight, how concentrated it is, and whether the pattern is consistent across examples or an artefact of one. Many heads turn out to do something mundane and useful, such as attending almost entirely to the previous token or to a sentence-initial position that acts as a no-op sink. Both are genuine findings; neither is an explanation of the model's output, because the row describes one routing step among dozens of layers and heads.

Why it matters

Attention maps are the standard illustration of what a language model is 'looking at', and they are routinely over-read. Knowing what a row does and does not license — it shows which value vectors were mixed at this step, not which input caused the final prediction — is the difference between a useful diagnostic and a misleading visualisation.

In the real world: Inspecting what a model focused on when it answered a question from a policy document.

Visual example

One query's row

The row for it in one head: most of the weight lands on the noun it refers to.

attention from "it" — layer 5, head 3
candidate tokenweightreading
animal61%the likely antecedent
it18%attending to itself
The9%a common low-information sink
(5 others)12%spread thinly
import torch

tokens = ["The", "animal", "did", "not", "cross", "it", "was", "tired"]
out, weights = mha(x, x, x, average_attn_weights=False)
row = weights[0, 3, 5]                 # example 0, head 3, query "it"

top = torch.topk(row, 3)
for weight, index in zip(top.values, top.indices):
    print(f"{tokens[index]:8s} {float(weight):.0%}")
# animal   61%
# it       18%
# The      09%

Presenting one cherry-picked head from one example as evidence of what the model learned. Check whether the pattern holds across examples and note which layer and head it came from; a single pretty map is an anecdote.

Practise it: Scaled Dot-Product Attention

Attention's quadratic cost

What it is

QKᵀ has one entry per query-key pair, so for a sequence of length n it is an n × n matrix that must be computed, softmaxed, and multiplied by V — O(n² · d) work, and in a naive implementation O(n²) memory per head per layer. The consequence is stark: doubling the context length quadruples the attention cost. At n = 512 the matrix is 262k entries; at n = 32,768 it is a billion, per head per layer. This is why context length is the expensive dimension of a language model, why FlashAttention (which never materialises the full matrix) matters so much in practice, and why sparse, linear and sliding-window attention variants exist at all.

Why it matters

It sets the real engineering constraint of every model in modules 17-18, and it is the reason a long-context feature is a serious systems undertaking rather than a configuration change. Naming it plainly also makes the RNN comparison honest: recurrence is O(n · d²) and linear in n, so for very long sequences its cost profile is genuinely better — attention's win is parallelism, not total arithmetic.

In the real world: Why long documents cost much more to process, and why chat products limit context length.

Visual example

Cost vs. context

Attention is quadratic in sequence length; recurrence is linear. That crossover is why long context is hard.

self-attention O(n²·d)recurrence O(n·d²)
02,0004,0006,0008,000010203040506070sequence length nrelative cost per layer64× the cost of n=5128× the cost of n=512

Attention still wins in wall-clock training time at usual lengths, because its work is one big parallel matmul while recurrence's is n sequential steps. The curves are arithmetic, not throughput.

import torch
import torch.nn.functional as F

for n in [512, 2048, 8192]:
    bytes_per_head = n * n * 4            # float32 scores
    print(f"n={n:5d}  {bytes_per_head / 1e6:8.1f} MB per head per layer")
# n=  512       1.0 MB
# n= 2048      16.8 MB
# n= 8192     268.4 MB   <- x8 the length, x64 the memory

# the fix: a kernel that never materialises the n x n matrix
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)

Estimating cost from parameter count. Attention adds no parameters as the sequence grows, so a 7B model's memory footprint at 32k context is dominated by activations and the key/value cache rather than by its weights — and that is what runs out first.

Practise it: Scaled Dot-Product Attention