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

Self-Attention

Letting every token retrieve context from other tokens in the same sequence.

At work: How language models read a whole document at once and understand how its words relate.

Scaled dot-product self-attention and masking

What it is

Self-attention derives queries, keys, and values from the same sequence, then computes softmax(QKᵀ / √d) V. The dot-product matrix contains every query–key compatibility score; dividing by √d keeps their scale manageable before softmax. Padding masks prevent attention to fake tokens added only to make batches rectangular, while causal masks prevent a next-token model from seeing future tokens. Multi-head attention repeats this with several smaller learned projections, then combines the resulting contexts.

Why it matters

The mask is part of the model definition. Without the causal mask, a language model can cheat during training by reading the answer it is asked to predict, producing impressive training loss and a useless generator. Standard attention also creates an n×n matrix, so context length affects memory and compute quadratically; efficient-attention methods are responses to that concrete bottleneck, not merely architectural fashion.

In the real world: How a language model relates every word in a contract to every other word.

Visual example

Interactive causal mask

Select a token: cells to its right are absent, so its prediction cannot use future information.

Choose a query token

“I” attends most to I · 100%

IIlovegoodcoffee

Attention matrix · rows are queries

IlovegoodcoffeeIlovegoodcoffee

Future-token cells are absent from each row: the causal mask makes them unavailable before softmax.

Why this matters

A causal mask changes what the model is permitted to know. It enforces the same left-to-right information boundary it will have while generating text.

Common misconception

Wrong idea: the model simply learns not to look right.

Correct idea: the mask removes future positions from the softmax entirely, so looking right is mathematically impossible during training.

import torch
import torch.nn.functional as F

x = torch.randn(2, 6, 64)                 # (batch, tokens, d_model)
q, k, v = W_q(x), W_k(x), W_v(x)          # all three from the SAME x

out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
out.shape                                  # (2, 6, 64)

Ignoring the quadratic attention matrix. Standard attention over length n stores and computes n² pairwise interactions, which dominates long-context memory and runtime.

Practise it: ReLU, Forward and Backward, Scaled Dot-Product Attention

Self- versus cross-attention

What it is

The mechanism is identical; only the source of the three tensors differs. In cross-attention — module 16's original encoder-decoder case — the queries come from one sequence and the keys and values from another, so the two can have different lengths and the attention matrix is rectangular: (target_len × source_len). In self-attention all three come from the same sequence, so the matrix is square (n × n) and its diagonal is a position attending to itself. The shape difference is the quickest way to tell which one a piece of code implements, and the reason nn.MultiheadAttention(q, k, v) takes three arguments rather than one.

Why it matters

Real Transformers use both, often in the same block. A translation decoder layer has self-attention over the partial output followed by cross-attention into the encoded source, and the two have different masks — the self-attention is causal, the cross-attention is not, because the whole source is legitimately available. Confusing them produces either a model that cannot see the source or one that cheats on its own output.

In the real world: Self-attention reads a document; cross-attention connects a question to that document when answering it.

Visual example

Same mechanism

One formula, two wirings. The attention matrix's shape tells you which is which.

where Q, K and V come from
cross-attentionself-attention
queries fromthe decoder sequencethe same sequence
keys / values fromthe encoder sequencethe same sequence
matrix shapetgt_len × src_lenn × n
typical maskpadding onlypadding + causal, if generative
import torch
import torch.nn as nn

mha = nn.MultiheadAttention(64, 4, batch_first=True)
tgt = torch.randn(2, 5, 64)      # 5 target tokens so far
src = torch.randn(2, 9, 64)      # 9 encoded source tokens

self_out, self_w = mha(tgt, tgt, tgt)     # self:  (2, 5, 5) — square
cross_out, cross_w = mha(tgt, src, src)   # cross: (2, 5, 9) — rectangular
self_w.shape, cross_w.shape

Applying a causal mask to cross-attention. The source sequence is fully available — masking its later positions hides input the model is entitled to, and the symptom is a translation model that ignores the end of every source sentence.

Practise it: Scaled Dot-Product Attention

Why self-attention parallelises

What it is

An RNN's timestep t cannot start until t − 1 finishes, so a 512-token sequence is 512 sequential operations however much hardware is available. Self-attention computes QKᵀ for all positions at once — one matrix multiplication — and every position's output is then a weighted sum over the same value tensor. There is no loop over the sequence during training. Two properties follow: the number of sequential steps per layer is 1 rather than n, and the path between any two positions is 1 rather than up to n, so gradients between distant tokens do not traverse the long chain that module 15's vanishing-gradient problem lived in.

Why it matters

This is the whole reason Transformers displaced recurrence at scale. It is not that attention represents language better in principle; it is that the same compute budget buys vastly more training throughput, and the architectures that could consume internet-scale corpora in reasonable wall-clock time were the ones that got large. Generation is the exception and remains sequential — one token at a time, whatever the architecture — which is why inference optimisation is a different problem from training throughput.

In the real world: The reason large models can be trained on huge datasets in weeks rather than years.

Visual example

Sequential depth

Per layer, on a sequence of length n, with d channels.

what changes when recurrence is removed
RNN layerself-attention layer
sequential operationsn1
max path between tokensn1
total workO(n · d²)O(n² · d)
parallel over the sequence?noyes

Common misconception

Wrong idea: Transformers are faster because they are more efficient.

Correct idea: they do more total work per layer. They win because that work is one parallel matmul instead of n dependent steps.

import torch
import torch.nn as nn

x = torch.randn(16, 512, 256)

# recurrence: 512 dependent steps, one after another
lstm = nn.LSTM(256, 256, batch_first=True)

# self-attention: the whole sequence in one batched matmul
attn = nn.MultiheadAttention(256, 8, batch_first=True)

lstm(x)[0].shape                                # (16, 512, 256)
attn(x, x, x, need_weights=False)[0].shape      # (16, 512, 256)
# identical shapes; the second has no sequential dependency to wait on

Expecting the same speedup at generation time. Autoregressive decoding appends one token per forward pass, so it is inherently sequential — and each step re-attends over everything so far, which is what the key/value cache exists to avoid recomputing.

Practise it: Scaled Dot-Product Attention

Causal (masked) self-attention

What it is

A next-token model must not let position i attend to any position after i, or it would have access to the token it is being asked to predict. The mask is applied to the scores *before* the softmax, by setting the disallowed entries to −inf: exp(−inf) = 0, so those positions get exactly zero weight and the remaining weights still sum to 1. The allowed pattern is lower-triangular including the diagonal, which is why the mask is built with torch.triu or torch.tril. Setting the weights to zero *after* the softmax is not equivalent — the row no longer sums to 1, and the gradient still flows through the masked scores.

Why it matters

This is the single most consequential mask in deep learning, and getting it wrong produces the most convincing-looking failure in the field: training loss drops to near zero because the model is reading the answer, and generation is incoherent because that information does not exist at inference. The loss curve looks like a triumph. It also explains a structural fact about training — one forward pass computes the loss for every position at once, which is only sound because the mask makes each position's prediction depend on its own prefix alone.

In the real world: Stops a text generator from seeing the words it's supposed to predict, which is how chatbots write one word at a time.

Visual example

The triangular mask

Lower-triangular including the diagonal: position i may attend to 0…i and nothing beyond.

allowed (✓) and masked (−inf) entries, n = 4
k: Ik: lovek: goodk: coffee
q: I✓−inf−inf−inf
q: love✓✓−inf−inf
q: good✓✓✓−inf
q: coffee✓✓✓✓

Row 2 (`good`) after masking

scores: [1.2, 2.0, 0.5, −inf]

exp: [3.3, 7.4, 1.6, 0.0]

sum = 12.3 -> weights [0.27, 0.60, 0.13, 0.00]

the future contributes nothing, and the row still totals 1

import torch
import torch.nn.functional as F

n = 4
mask = torch.triu(torch.ones(n, n, dtype=torch.bool), diagonal=1)
scores = torch.randn(n, n)
scores = scores.masked_fill(mask, float("-inf"))   # BEFORE the softmax
weights = F.softmax(scores, dim=-1)

weights.sum(-1)        # tensor([1., 1., 1., 1.]) — still a distribution
weights[0]             # tensor([1., 0., 0., 0.]) — token 0 sees only itself

out = F.scaled_dot_product_attention(q, k, v, is_causal=True)   # the built-in

Masking after the softmax by multiplying the weights by a 0/1 matrix. The surviving weights then sum to less than 1, so early positions' outputs are systematically scaled down relative to later ones — a subtle, position-dependent error that no shape check will catch.

Practise it: Scaled Dot-Product Attention

Positional encoding

What it is

Self-attention has no notion of order. Permute the input tokens and the set of attention scores is the same set, permuted identically — so 'dog bites man' and 'man bites dog' produce the same representations, reordered. An RNN got order for free from the sequential update; attention must be told. The fix is to add a position-dependent vector to each token embedding before the first layer. The original sinusoidal scheme uses sines and cosines at geometrically spaced frequencies, which needs no parameters and extends to lengths never seen in training; learned absolute embeddings are a simple table lookup; and rotary embeddings (RoPE), now the common choice, rotate the queries and keys by a position-dependent angle so that the dot product depends on *relative* position.

Why it matters

It is not a refinement but a requirement — without it the model is a bag-of-tokens processor, and the failure is total rather than gradual. The scheme also decides how far the model extrapolates: absolute learned embeddings have nothing to look up past their table size, which is a large part of why extending a model's context window is more involved than raising a number in a config.

In the real world: Tells the model word order, so “dog bites man” and “man bites dog” mean different things.

Visual example

Sinusoidal encoding

Different dimensions oscillate at different rates, so the vector at each position is unique and nearby positions are similar.

dim 0 (fast)dim 4 (medium)dim 12 (slow)
05101520-1-0.500.51token positionencoding value

Why this matters

It shows why the scheme needs no parameters and no maximum length: the encoding for position 5,000 is just the same functions evaluated further along.

Common misconception

Wrong idea: positional encodings are concatenated onto the token embedding.

Correct idea: they are added to it. The model dimension does not grow, and the first layer's projections learn to separate the two contributions.

The slow dimensions distinguish coarse position across a long document; the fast ones distinguish neighbouring tokens. Together they give every position a distinct signature.

import torch
import math

def sinusoidal(n, d):
    pos = torch.arange(n).unsqueeze(1)
    i = torch.arange(0, d, 2)
    angle = pos / torch.pow(10000, i / d)
    pe = torch.zeros(n, d)
    pe[:, 0::2], pe[:, 1::2] = torch.sin(angle), torch.cos(angle)
    return pe

x = embeddings + sinusoidal(x.size(1), x.size(2))   # add, do not concatenate
math.isclose(float(sinusoidal(8, 16)[0, 0]), 0.0)    # position 0: sin(0) = 0

Adding positional encodings to the queries and keys inside every layer as well as to the input embeddings, or forgetting them entirely. The second is the dangerous one: the model still trains and its loss still falls, because much of language is predictable from word identity alone — it is simply much worse than it should be, for no visible reason.

Self-attention worked through by hand

What it is

On a three-token toy sequence with d = 2, the whole operation is four small steps and worth doing once. Project each token to a query, key and value; compute every query-key dot product and divide by √d; softmax each row; and take the weighted sum of the value vectors per row. The point of doing it numerically is to see two things concretely: the output for a position is a blend of *other positions'* values, so the representation genuinely changes based on context, and the arithmetic is nothing but two matrix multiplications with a softmax between them.

Why it matters

Self-attention is usually met as a formula and remains abstract until a row has been computed by hand. It also makes the shapes unforgettable — (n × d) @ (d × n) → (n × n), then (n × n) @ (n × d) → (n × d) — which is the chain every shape bug in a Transformer is measured against.

In the real world: Understanding the mechanism makes it easier to debug and choose models for a product.

Visual example

3 tokens, d = 2

Each output row is a blend of all three value vectors, weighted by that token's row of the attention matrix.

attention weights (rows sum to 1)
t0t1t2
t00.420.210.42
t10.210.420.42
t20.260.260.48
softmax(QKᵀ/√d) @ V
output = weights @ V
out₀out₁
t06.34.2
t14.26.3
t255

Row t0, step by step

V = [[10, 0], [0, 10], [5, 5]]

0.42·[10, 0] = [4.2, 0.0]

0.21·[0, 10] = [0.0, 2.1]

0.42·[5, 5] = [2.1, 2.1]

sum = [6.3, 4.2] — t0's new representation

Shapes: (3×2) @ (2×3) → (3×3) scores, then (3×3) @ (3×2) → (3×2) output. The sequence length and the model dimension never get confused if you follow that chain.

import torch
import torch.nn.functional as F

q = torch.tensor([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])   # 3 tokens, d=2
k = q.clone()
v = torch.tensor([[10.0, 0.0], [0.0, 10.0], [5.0, 5.0]])

scores = q @ k.T / 2 ** 0.5
# [[0.71, 0.00, 0.71], [0.00, 0.71, 0.71], [0.71, 0.71, 1.41]]
weights = F.softmax(scores, dim=-1)
# row 0: [0.42, 0.21, 0.42]  (approximately)
out = weights @ v
# row 0: 0.42*[10,0] + 0.21*[0,10] + 0.42*[5,5] = [6.3, 4.2]
out.shape        # (3, 2)

Transposing the wrong axes on a batched tensor. q @ k.T is right for a 2-D tensor and wrong for (batch, n, d), where it must be k.transpose(-2, -1) — .T on a 3-D tensor reverses every axis and produces a silently meaningless product.

Practise it: Scaled Dot-Product Attention

The residual-and-norm wrapping

What it is

A self-attention sublayer is never used bare. In practice it is wrapped: x = x + Attention(LayerNorm(x)), and the feedforward sublayer beside it is wrapped the same way. The residual addition is module 10's gradient highway, which is what makes a 96-layer stack trainable at all; the LayerNorm is module 11's scale control, chosen over BatchNorm because it works at batch size 1 and never couples examples. The placement matters: pre-norm — normalizing the sublayer's *input*, as written above — leaves the residual path completely clean and trains stably without careful warmup, which is why modern Transformers use it in preference to the original post-norm arrangement.

Why it matters

It explains why self-attention alone is not a layer you can stack. Without the residual, a deep stack hits exactly module 10's vanishing gradients; without the norm, the activation scale drifts as the residual stream accumulates contributions from every block. The wrapping is also the structural unit module 18 assembles, so recognising it here means the transformer block introduces nothing new.

In the real world: Part of what lets very deep language models train stably.

Visual example

Deep learning

Pre-norm, attention, residual add. The residual path never passes through a transformation.

One self-attention sublayer, as actually used

  1. x (the residual stream)(batch, n, d_model)
  2. LayerNorm(x)per-token scale control (module 11)
  3. MultiheadAttentiontokens exchange information
  4. x + attention outputthe clean gradient route (module 10)

Stack this with a feedforward sublayer wrapped the same way and you have a transformer block.

import torch.nn as nn

class SelfAttentionBlock(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.norm = nn.LayerNorm(d_model)
        self.attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True)

    def forward(self, x, mask=None):
        h = self.norm(x)                                    # pre-norm
        h, _ = self.attn(h, h, h, attn_mask=mask, need_weights=False)
        return x + h                                        # residual add

Writing return self.norm(x + h) — the original post-norm form — in a deep stack without warmup. The residual path now passes through a normalization at every block, so it is no longer an unmultiplied route for the gradient, and training a deep model becomes markedly more fragile.

Forgetting the causal mask

What it is

The failure is worth stating as a scenario, because it is so easy to produce and so convincing while it happens. Training a next-token model with is_causal=False or with no attn_mask, every position attends to the whole sequence including the token it is predicting. Training loss falls to a fraction of what any honest model achieves; validation loss, computed the same unmasked way, falls with it. Nothing looks wrong until generation, where each step genuinely has no future context and the model — which learned to read the answer rather than to predict it — produces fluent-looking nonsense.

Why it matters

It is the clearest example of an evaluation that confirms a bug instead of catching it, because the leak is present in both splits. The lesson generalises past masks: when training and validation share a shortcut, the validation number cannot detect it, and only a test of the *inference procedure* — actually generating, or evaluating with the mask the model will run under — can.

In the real world: A bug that makes a text model look brilliant in training and write nonsense in production.

import torch
import torch.nn.functional as F

# the leak: no mask, so position i sees token i+1
leaky = F.scaled_dot_product_attention(q, k, v)

# the check: the loss should be nowhere near zero
logits = model(tokens[:, :-1])
loss = F.cross_entropy(logits.flatten(0, 1), tokens[:, 1:].flatten())
assert loss > 0.5, f"loss {loss.item():.4f} is too good — is the mask on?"

# and confirm the mask exists where it should
assert model.blocks[0].attn_mask is not None

Trusting a suspiciously low training loss. A character-level model reaching a loss of 0.01 in one epoch has not learned English; it has found the answer in its input. Treat an implausibly good loss as a bug report.