Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
An autoencoder turned an input patch into a compact representation. A Transformer must build a useful representation at every token position, because an incident note such as Key blocked by depends on what came earlier and must still predict what comes next.
Follow a small decoder-only Transformer forward pass. The dimensions stay tiny enough to print, then the same operations get assembled in PyTorch. Earlier lessons introduced cross-entropy; the next chapter applies it to shifted next-token targets across a sequence.

The Transformer introduced in 2017 used an encoder and a decoder for translation. Its decoder combines masked self-attention, encoder-decoder attention, and a feed-forward network. A text-generation decoder-only model keeps the causal self-attention path and predicts the next token from preceding context.[1][2]
Tokens become positioned vectors
Use a simplified incident-policy vocabulary. We won't claim these are IDs from a production tokenizer; we're choosing IDs deliberately so the forward pass is readable:
| Text so far | Token IDs | Desired continuation |
|---|---|---|
Key blocked by | [0, 1, 2] | likely policy |
A model doesn't operate on token labels. It looks up one learned vector per ID, then adds or applies position information so blocked by differs from by blocked. In this first calculation, position is a small vector added to each token embedding.
1import numpy as np
2
3tokens = ["Key", "blocked", "by"]
4ids = np.array([0, 1, 2])
5embedding_table = np.array([
6 [1.0, 0.0, 0.4, 0.0], # Key
7 [0.2, 1.0, 0.5, 0.0], # blocked
8 [0.0, 0.3, 0.8, 1.0], # by
9 [0.1, 0.5, 1.0, 0.9], # policy
10])
11position = np.array([
12 [0.00, 0.00, 0.00, 0.00],
13 [0.05, 0.00, 0.00, 0.05],
14 [0.10, 0.00, 0.00, 0.10],
15])
16
17x = embedding_table[ids] + position
18print("tokens:", tokens)
19print("ids shape:", ids.shape)
20print("hidden shape:", x.shape)
21print("last positioned vector:", np.round(x[-1], 2))1tokens: ['Key', 'blocked', 'by']
2ids shape: (3,)
3hidden shape: (3, 4)
4last positioned vector: [0.1 0.3 0.8 1.1]Our tiny model uses three tokens and four features, so its hidden-state matrix has shape [3, 4]. Real models use far wider vectors and batches, but the axes mean the same thing:
| Axis | Tiny example | General name |
|---|---|---|
| Number of sequences | omitted | B (batch size) |
| Token positions | 3 | T (sequence length) |
| Features per token | 4 | d_model (model width) |
![Diagram showing Positioned vectors Key blocked by [T, d_model], N masked blocks [T, d_model], Final norm + head Last-position logits [vocab], and Next token policy.](/cdn/content-image/preparation/transformer-architecture-end-to-end/diagrams/_generated/content_diagram_0_dark.png?v=5c37ba77fa7e)
Attention lets a token read earlier tokens
An RNN transports information through one recurrent state. Self-attention takes a different route: at each position it computes relevance scores against other visible positions, then blends their value vectors. For a decoder, "visible" means the current and earlier tokens only.
Each token produces three learned projections:
- A query asks what information this position needs.
- A key advertises what a position contains.
- A value supplies information if the query matches its key.
The projections come from the same hidden-state matrix x, using different learned weights W_q, W_k, and W_v.[1]
To see the multiplication, take one two-feature attention head. These arrays stand in for outputs of its learned projections:
1import numpy as np
2
3q = np.array([
4 [1.0, 0.0], # Key asks mainly for credential context
5 [0.5, 1.0], # blocked asks for credential and action context
6 [0.2, 1.2], # by asks strongly for policy context
7])
8k = np.array([
9 [1.0, 0.0],
10 [0.4, 1.0],
11 [0.0, 0.8],
12])
13v = np.array([
14 [1.0, 0.0],
15 [0.3, 1.0],
16 [0.0, 0.6],
17])
18
19raw_scores = q @ k.T
20print("Q shape:", q.shape, "K shape:", k.shape, "V shape:", v.shape)
21print("Q @ K.T shape:", raw_scores.shape)
22print("raw score row for 'by':", np.round(raw_scores[2], 2))1Q shape: (3, 2) K shape: (3, 2) V shape: (3, 2)
2Q @ K.T shape: (3, 3)
3raw score row for 'by': [0.2 1.28 0.96]Three queries compared with three keys produce a [3, 3] matrix. The square T x T attention-score matrix is why full attention becomes expensive as context length grows: doubling T gives four times as many pair scores.
Scale before softmax
Scaled dot-product attention divides query-key scores by the square root of the head width, then applies softmax:
Here, d_k is the number of features in one key or query vector. Vaswani et al. explain that without scaling, larger dot products push softmax toward very small gradients when the key width is large.[1]
This calculation uses a 64-feature head and compares unscaled and scaled scores for one token:
1import numpy as np
2
3def softmax(values):
4 shifted = values - values.max()
5 exp = np.exp(shifted)
6 return exp / exp.sum()
7
8scores = np.array([12.4, 9.1, 3.8])
9scaled = scores / np.sqrt(64)
10
11print("unscaled weights:", np.round(softmax(scores), 3))
12print("scaled scores:", np.round(scaled, 3))
13print("scaled weights:", np.round(softmax(scaled), 3))1unscaled weights: [0.964 0.036 0. ]
2scaled scores: [1.55 1.138 0.475]
3scaled weights: [0.499 0.33 0.17 ]Without scaling, nearly all attention mass lands on one key. Scaling doesn't prevent a confident choice after training; it prevents the head width alone from making early scores overly sharp.
The causal mask prevents answer leakage
During training, the model can process Key blocked by policy in one matrix operation. Position by is supposed to predict policy, so it must not read the policy representation first. A causal mask sets future scores to negative infinity before softmax; those entries receive probability zero.
| Query position | Can read |
|---|---|
Key | Key |
blocked | Key, blocked |
by | Key, blocked, by |
policy | all four positions |
1import numpy as np
2
3scores = np.array([
4 [2.0, 1.5, 0.5, 1.0],
5 [1.0, 2.5, 1.5, 0.5],
6 [0.5, 1.0, 3.0, 2.0],
7 [1.5, 0.5, 1.0, 2.5],
8])
9future = np.triu(np.ones_like(scores, dtype=bool), k=1)
10masked_scores = np.where(future, -np.inf, scores)
11
12shifted = masked_scores - masked_scores.max(axis=-1, keepdims=True)
13weights = np.exp(shifted)
14weights /= weights.sum(axis=-1, keepdims=True)
15
16print("masked weights:")
17print(np.round(weights, 3))
18print("future attention mass:", float(weights[future].sum()))1masked weights:
2[[1. 0. 0. 0. ]
3 [0.182 0.818 0. 0. ]
4 [0.067 0.111 0.821 0. ]
5 [0.213 0.078 0.129 0.579]]
6future attention mass: 0.0The important output is future attention mass: 0.0. If a causal language model reports nonzero future attention during training, it has a data-leak bug, not an impressive loss curve.
Why can't the representation at by attend to policy while training a decoder-only next-token model?
Answer
policy is the label the by position is supposed to predict. Letting by read it would expose the answer before prediction, so training loss would no longer measure generation from preceding context.
Heads run in parallel; blocks run in sequence
One attention head can learn one set of relevance patterns. Multi-head attention projects the model features into several heads, applies attention in parallel, concatenates their outputs, and projects back to d_model.[1]
Suppose our tiny model width is four and it has two heads. Each head owns two features. Splitting and merging must preserve every token position:
1import numpy as np
2
3batch, tokens, d_model, heads = 1, 3, 4, 2
4d_head = d_model // heads
5projected = np.arange(batch * tokens * d_model).reshape(batch, tokens, d_model)
6
7split = projected.reshape(batch, tokens, heads, d_head).transpose(0, 2, 1, 3)
8merged = split.transpose(0, 2, 1, 3).reshape(batch, tokens, d_model)
9
10print("per-head shape:", split.shape)
11print("merged shape:", merged.shape)
12print("merge restores values:", bool(np.array_equal(projected, merged)))1per-head shape: (1, 2, 3, 2)
2merged shape: (1, 3, 4)
3merge restores values: TrueThe head axis is parallel work inside one block. The block axis is sequential depth: output from block 1 enters block 2. Mixing these concepts leads to configuration and shape mistakes.
A block alternates communication and local transformation
Attention lets token positions exchange information. A feed-forward network (FFN) then transforms each position independently, using the same weights at every position. The original Transformer used a two-layer FFN with a ReLU nonlinearity between the layers.[1]
The first projection commonly widens the feature dimension; the second returns it to d_model, which keeps blocks stackable.
1import numpy as np
2
3x = np.array([
4 [1.0, 0.0, 0.4, 0.0],
5 [0.2, 1.0, 0.5, 0.0],
6 [0.0, 0.3, 0.8, 1.0],
7])
8w1 = np.array([
9 [1.0, 0.0, 0.5, 0.0, 0.0, 0.2],
10 [0.0, 1.0, 0.0, 0.5, 0.2, 0.0],
11 [0.4, 0.2, 1.0, 0.0, 0.0, 0.5],
12 [0.0, 0.2, 0.0, 1.0, 0.4, 0.0],
13])
14w2 = np.array([
15 [0.3, 0.0, 0.0, 0.1],
16 [0.0, 0.3, 0.1, 0.0],
17 [0.2, 0.0, 0.3, 0.0],
18 [0.0, 0.2, 0.0, 0.3],
19 [0.1, 0.0, 0.0, 0.2],
20 [0.0, 0.1, 0.2, 0.0],
21])
22
23expanded = np.maximum(0.0, x @ w1)
24ffn_output = expanded @ w2
25print("input shape:", x.shape)
26print("expanded shape:", expanded.shape)
27print("returned shape:", ffn_output.shape)
28print("last-token update:", np.round(ffn_output[-1], 3))1input shape: (3, 4)
2expanded shape: (3, 6)
3returned shape: (3, 4)
4last-token update: [0.302 0.468 0.386 0.469]Residual paths and normalization
A residual connection adds a sublayer result back to its input. It creates an identity path through deep stacks, following the residual-network idea introduced by He et al.[3] Layer normalization computes statistics within each token vector; its learned scale and offset can then adjust the normalized output.[4]
There are multiple valid placements. The 2017 Transformer applied normalization after each residual addition. The diagram and PyTorch blocks later in this chapter use pre-normalization: normalize first, compute the sublayer update, then add it to the residual stream. The two layouts share attention, FFN, and residual concepts; don't accidentally describe one while coding the other.[1] Xiong et al. connect Pre-LN with better-behaved gradients at initialization and show that it can train without learning-rate warmup in their evaluated tasks.[5]
This NumPy calculation demonstrates the pre-normalization order by normalizing a tensor before adding a small simulated update:
1import numpy as np
2
3def normalize(x, eps=1e-5):
4 mean = x.mean(axis=-1, keepdims=True)
5 variance = ((x - mean) ** 2).mean(axis=-1, keepdims=True)
6 return (x - mean) / np.sqrt(variance + eps)
7
8x = np.array([
9 [1.0, 2.0, 0.0, 1.0],
10 [0.2, 0.4, 0.8, 0.6],
11])
12normalized = normalize(x)
13sublayer_update = 0.1 * normalized
14residual_output = x + sublayer_update
15
16print("normalized means:", np.round(normalized.mean(axis=-1), 6))
17print("normalized variances:", np.round(normalized.var(axis=-1), 4))
18print("residual output shape:", residual_output.shape)1normalized means: [0. 0.]
2normalized variances: [1. 0.9998]
3residual output shape: (2, 4)
Implement masked attention in PyTorch
Now implement a single causal multi-head self-attention layer. The projections are learned linear maps; the mask is created from token positions. masked_fill is the key line: it turns every future score into negative infinity before softmax.
1import math
2import torch
3from torch import nn
4
5class CausalSelfAttention(nn.Module):
6 def __init__(self, d_model=8, heads=2):
7 super().__init__()
8 assert d_model % heads == 0
9 self.heads = heads
10 self.d_head = d_model // heads
11 self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
12 self.out = nn.Linear(d_model, d_model, bias=False)
13
14 def forward(self, x):
15 batch, tokens, d_model = x.shape
16 q, k, v = self.qkv(x).chunk(3, dim=-1)
17 def split_heads(tensor):
18 return tensor.view(batch, tokens, self.heads, self.d_head).transpose(1, 2)
19 q, k, v = map(split_heads, (q, k, v))
20
21 scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_head)
22 future = torch.triu(
23 torch.ones(tokens, tokens, dtype=torch.bool, device=x.device),
24 diagonal=1,
25 )
26 weights = torch.softmax(scores.masked_fill(future, float("-inf")), dim=-1)
27 mixed = weights @ v
28 merged = mixed.transpose(1, 2).contiguous().view(batch, tokens, d_model)
29 return self.out(merged), weights
30
31torch.manual_seed(4)
32x = torch.randn(1, 4, 8)
33attention = CausalSelfAttention()
34output, weights = attention(x)
35future = torch.triu(torch.ones(4, 4, dtype=torch.bool), diagonal=1)
36
37print("attention output shape:", tuple(output.shape))
38print("weights shape:", tuple(weights.shape))
39print("largest future weight:", f"{weights[0, :, future].max().item():.6f}")1attention output shape: (1, 4, 8)
2weights shape: (1, 2, 4, 4)
3largest future weight: 0.000000The output shape matches the input shape, while the [batch, heads, tokens, tokens] weight tensor exposes each head's routing decisions.
Assemble one decoder block
With attention implemented, a small pre-normalized block is short: normalize, attend, add; normalize, run FFN, add. PyTorch's LayerNorm includes the learned scale and offset that the earlier NumPy calculation omitted. This block uses GELU instead of the original Transformer's ReLU, so you can see that the activation choice can change while the FFN's structural role stays the same: widen, transform, and return to d_model.
1import math
2import torch
3from torch import nn
4
5class CausalSelfAttention(nn.Module):
6 def __init__(self, d_model=8, heads=2):
7 super().__init__()
8 assert d_model % heads == 0
9 self.heads = heads
10 self.d_head = d_model // heads
11 self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
12 self.out = nn.Linear(d_model, d_model, bias=False)
13
14 def forward(self, x):
15 batch, tokens, d_model = x.shape
16 q, k, v = self.qkv(x).chunk(3, dim=-1)
17 q = q.view(batch, tokens, self.heads, self.d_head).transpose(1, 2)
18 k = k.view(batch, tokens, self.heads, self.d_head).transpose(1, 2)
19 v = v.view(batch, tokens, self.heads, self.d_head).transpose(1, 2)
20 scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_head)
21 mask = torch.triu(
22 torch.ones(tokens, tokens, dtype=torch.bool, device=x.device),
23 diagonal=1,
24 )
25 weights = torch.softmax(scores.masked_fill(mask, float("-inf")), dim=-1)
26 joined = (weights @ v).transpose(1, 2).contiguous().view(batch, tokens, d_model)
27 return self.out(joined)
28
29class DecoderBlock(nn.Module):
30 def __init__(self, d_model=8, heads=2, d_ff=24):
31 super().__init__()
32 self.norm1 = nn.LayerNorm(d_model)
33 self.attention = CausalSelfAttention(d_model, heads)
34 self.norm2 = nn.LayerNorm(d_model)
35 self.ffn = nn.Sequential(
36 nn.Linear(d_model, d_ff),
37 nn.GELU(),
38 nn.Linear(d_ff, d_model),
39 )
40
41 def forward(self, x):
42 x = x + self.attention(self.norm1(x))
43 x = x + self.ffn(self.norm2(x))
44 return x
45
46torch.manual_seed(5)
47x = torch.randn(2, 4, 8, requires_grad=True)
48block = DecoderBlock()
49output = block(x)
50output.square().mean().backward()
51
52print("input -> output:", tuple(x.shape), "->", tuple(output.shape))
53print("input gradient is finite:", bool(torch.isfinite(x.grad).all()))1input -> output: (2, 4, 8) -> (2, 4, 8)
2input gradient is finite: TrueThis block doesn't generate a word yet. It produces contextual vectors with the same [B, T, d_model] shape as its input, so another block can consume them.
Project the final position into a vocabulary
After the final block, many pre-normalized decoder stacks apply one final normalization before the output head. For example, GPT-2 applies its final ln_f immediately before computing logits.[6] A learned output matrix then converts each hidden vector into one raw score per vocabulary item. These scores are logits. During generation, use only the final position's scores because that position represents all text currently visible to the decoder. The small calculation below starts from an already normalized final-position vector.
1import numpy as np
2
3vocab = np.array(["policy", "today", "late", "auth", "token", "service"])
4last_hidden_normalized = np.array([0.7, -0.2, 0.5, 0.1])
5output_weight = np.array([
6 [1.714, 0.0, 0.0, 0.0, 0.0, 0.0],
7 [0.0, -1.75, 0.0, 0.0, 0.0, 2.0],
8 [0.0, 0.0, 0.8, 0.0, 0.24, 0.0],
9 [0.0, 0.0, 0.0, 0.5, 0.0, -3.0],
10])
11
12logits = last_hidden_normalized @ output_weight
13probabilities = np.exp(logits - logits.max())
14probabilities /= probabilities.sum()
15best = probabilities.argmax()
16
17print("logits shape:", logits.shape)
18print("top token:", vocab[best])
19print("top probability:", f"{probabilities[best]:.3f}")1logits shape: (6,)
2top token: policy
3top probability: 0.373
The result policy completes Key blocked by policy. Conceptually, generate a longer answer by appending that token, running the stack with the longer context, and choosing the next one. Optimized serving reuses earlier attention keys and values through a KV cache instead of recomputing them for every new token. The next chapter connects this forward pass to shifted targets and the cross-entropy objective introduced earlier.
Encoder, decoder, and encoder-decoder visibility
Decoder-only generation is one member of the Transformer family. Architecture choice is largely a question of which positions may read which information:
| Variant | Visibility rule | Suitable lesson example | Representative source |
|---|---|---|---|
| Encoder-only | Each input position can read both left and right context | Classify a code review after reading the full note | BERT[7] |
| Decoder-only | Each position can read only itself and prior tokens | Generate an incident explanation left to right | GPT[2] |
| Encoder-decoder | Encoder reads source; decoder reads source plus prior output tokens | Translate a deployment rollback policy | T5[8] |

In self-attention, queries, keys, and values come from one sequence. In encoder-decoder cross-attention, decoder queries read keys and values produced by the encoder. A plain decoder-only text generator has no separate encoder sequence, so its core path is causal self-attention.
Diagnose a broken forward pass
The fastest way to debug a Transformer is to ask what invariant was violated:
| Symptom | Likely cause | Check |
|---|---|---|
| Training loss looks suspiciously tiny, but generation fails | Future tokens leaked through attention | Assert masked future attention mass is zero |
| Head split crashes or silently produces the wrong shape | d_model isn't divisible by n_heads, or axes were transposed incorrectly | Check [B, H, T, d_head] then merge back to [B, T, d_model] |
| Attention works on CPU but fails after moving the block to a GPU | Causal mask stayed on CPU while scores moved to the accelerator | Create the mask on x.device |
| A block can't be stacked with another block | FFN or output projection changed model width too early | Keep block output at d_model; project to vocabulary only at the end |
| Explanation says decoder-only model reads an encoder | Self-attention and cross-attention were confused | Identify whether a separate source sequence exists |
Use a deliberately bad mask to make leakage visible:
1import numpy as np
2
3scores = np.array([
4 [2.0, 5.0, 4.0],
5 [1.0, 2.0, 6.0],
6 [0.5, 1.0, 2.0],
7])
8future = np.triu(np.ones_like(scores, dtype=bool), k=1)
9
10def softmax_rows(matrix):
11 exp = np.exp(matrix - matrix.max(axis=-1, keepdims=True))
12 return exp / exp.sum(axis=-1, keepdims=True)
13
14unmasked = softmax_rows(scores)
15masked = softmax_rows(np.where(future, -np.inf, scores))
16print("unmasked future mass:", f"{unmasked[future].sum():.3f}")
17print("masked future mass:", f"{masked[future].sum():.3f}")1unmasked future mass: 1.940
2masked future mass: 0.000If that second value isn't zero in a causal decoder, fix the mask before trusting any metric.
Mastery check
Key concepts
- Token IDs become positioned vectors with shape
[B, T, d_model]. - Scaled dot-product attention mixes visible token values using query-key relevance.
- A causal mask gives future positions zero attention weight.
- Heads run in parallel inside a block; blocks run sequentially.
- Attention communicates across positions; the FFN transforms each position.
- Residual paths and normalization let same-shaped blocks stack.
- In the pre-LN pipeline shown here, a final normalization prepares the residual stream before the output projection turns the last hidden state into next-token logits.
Evaluation rubric
- Foundational: Traces
Key blocked byfrom token IDs through hidden states to a next-token distribution. - Foundational: Explains why the causal mask must hide
policyfrom thebyposition during training. - Intermediate: Derives the score and output shapes for one attention head and for a multi-head merge.
- Intermediate: Implements causal attention with a future-mask assertion.
- Intermediate: Explains attention, FFN, residual, and normalization roles without treating them as interchangeable.
- Advanced: Distinguishes encoder-only, decoder-only, and encoder-decoder visibility rules and selects a variant for a stated task.
Common pitfalls
- Treating query, key, or value as a fixed property of a word rather than a learned per-layer projection.
- Applying softmax before causal masking, which allows future answers to influence the output.
- Confusing parallel attention heads with sequential decoder blocks.
- Projecting to vocabulary size inside every block instead of once after the stack.
- Assuming every Transformer has the same normalization placement or cross-attention path.
Follow-up questions
For an input tensor with shape [2, 10, 768] and 12 heads, what is each head's query shape?
Answer
Each head has width 768 / 12 = 64, so one head's query tensor has shape [2, 10, 64]. Across all heads after splitting, the shape is [2, 12, 10, 64].
Why is a Transformer decoder block able to stack many times without a shape adapter?
Answer
Both masked attention and the FFN return to d_model, and residual additions require the sublayer output to match its input. A block therefore maps [B, T, d_model] back to [B, T, d_model].
When would cross-attention appear in a Transformer used for deployment-policy translation?
Answer
An encoder reads the original policy text. While generating translated text, the decoder forms queries from its partial translation and reads encoder keys and values through cross-attention.
What test catches the most damaging causal-attention implementation error?
Answer
Inspect attention weights at masked future locations and assert their total or maximum value is zero after softmax. Nonzero future weight means the model can read target tokens during training.