Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Autocomplete has read function returns the and needs one more token. result feels plausible, but picking it requires each token to carry its own information while the consults its history without peeking at the answer. What changes between that text and the final choice?
Follow one small decoder-only Transformer forward pass. Three token IDs become positioned vectors, attention exchanges information along legal paths, a block updates every row without changing its width, and the final row becomes vocabulary scores. Small NumPy experiments expose individual operations; three linked PyTorch cells then assemble the complete model. The weights are illustrative, not trained, so a plausible continuation isn't evidence of language understanding.
The preceding autoencoder lesson squeezed an input through a narrow latent code. Here we keep one vector per token and let those vectors communicate. You'll reuse matrix multiplication, nonlinear layers, and the softmax calculation from Softmax & Cross-Entropy. No new differentiation rules are needed for this forward pass.
Before reading the pipeline, predict two shapes: with three tokens and width four, the hidden state should stay [3, 4]; with six vocabulary entries, the selected row should produce six logits. Keep those predictions in view as the data moves.

That path is what we mean by a Transformer here: a stack of attention and position-wise feed-forward updates that carries a same-width representation forward.
The architecture introduced in 2017 paired an encoder with a decoder for translation. A text-generation decoder-only model keeps the decoder's masked self-attention path and predicts each token from preceding context.[1][2]
Tokens become positioned vectors
Start at the input boundary with a tiny autocomplete vocabulary. These aren't IDs from a production tokenizer. We're choosing them so the forward pass is readable:
| Text so far | Token IDs | Desired continuation |
|---|---|---|
function returns the | [0, 1, 2] | likely result |
A model doesn't operate on raw token strings. It looks up one learned vector per ID, then adds position information so returns the differs from the returns. Without positional cues, pure self-attention is permutation equivariant: permuting the rows of an input matrix produces the exact same permutation in the output. Shuffling function returns the into the returns function would yield the same token vectors in shuffled row order. Adding or injecting position breaks that symmetry so the network knows the follows returns.
Architectures handle position in three distinct ways:
- Fixed sinusoidal encodings: The original 2017 Transformer generated sine and cosine waves of varying frequencies, allowing relative shifts to be expressed as linear transformations without adding learned parameters.[1]
- Learned absolute positional embeddings: GPT and BERT learned an explicit embedding table , adding row directly to token .[2][3] While simple, this creates a hard cutoff: the model can't process sequences longer than .
- Rotary Position Embedding (RoPE): Modern LLMs such as Llama and Mistral rotate query and key vectors inside each head by an angle proportional to sequence index.[4][5] Their dot product then depends directly on relative token distance , enabling context extension.
Our arithmetic uses small additive vectors to keep every coordinate inspectable by hand:
1import numpy as np
2
3tokens = ["function", "returns", "the"]
4ids = np.array([0, 1, 2])
5embedding_table = np.array([
6 [1.0, 0.0, 0.4, 0.0], # function
7 [0.2, 1.0, 0.5, 0.0], # returns
8 [0.0, 0.3, 0.8, 1.0], # the
9 [0.1, 0.5, 1.0, 0.9], # result
10 [0.0, 0.1, 0.0, 0.1], # None
11 [0.2, 0.0, 0.4, 0.5], # value
12])
13position = np.array([
14 [0.00, 0.00, 0.00, 0.00],
15 [0.05, 0.00, 0.00, 0.05],
16 [0.10, 0.00, 0.00, 0.10],
17])
18
19x = embedding_table[ids] + position
20print("tokens:", tokens)
21print("ids shape:", ids.shape)
22print("hidden shape:", x.shape)
23print("last positioned vector:", np.round(x[-1], 2))1tokens: ['function', 'returns', 'the']
2ids shape: (3,)
3hidden shape: (3, 4)
4last positioned vector: [0.1 0.3 0.8 1.1]A row belongs to one token position; a column is one feature of that position. 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) |
The full vocabulary is function, returns, the, result, None, value, in that ID order. The output head will score those same six tokens. Input and output weights needn't be tied, but a generated ID must be meaningful when fed back into the embedding table.
![Diagram showing Positioned vectors function returns the [T, d_model], N masked blocks [T, d_model], Final norm + head Last-position logits [vocab], and Next token result.](/cdn/content-image/preparation/transformer-architecture-end-to-end/diagrams/_generated/content_diagram_0_dark.png?v=82c5f38558d7)
Attention lets a token read earlier tokens
At the, the model needs a useful read of its history. An RNN transports information through one recurrent state. Self-attention takes a different route: every position computes a read from the visible positions, then blends their value vectors.
For a decoder, "visible" means the current and earlier tokens only. That rule lets the model process a whole training sequence without letting a later answer flow backward.
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.
Why separate queries, keys, and values instead of dotting the hidden state with itself? Direct self-matching creates a symmetric score matrix where token A attends to token B with the exact same weight that token B attends to token A. Human language isn't symmetric: in function returns the, returns needs to inspect its subject function, but function doesn't need to read returns with equal strength. Three separate projection matrices decouple those roles, allowing queries to search for specific properties while keys advertise them.[1]
Before checking the numbers, predict the score shape: three query rows compared with three key rows should produce a [3, 3] matrix. The the row can score all three keys at this stage; the causal mask will remove any illegal entries next.
To see the multiplication, take one two-feature attention head. These handpicked arrays isolate the dot product; they aren't the projections used by the complete model later:
1import numpy as np
2
3q = np.array([
4 [1.0, 0.0], # function position
5 [0.5, 1.0], # returns position
6 [0.2, 1.2], # the position
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 'the':", 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 'the': [0.2 1.28 0.96]There is one score for every query-key pair. That square T x T matrix also explains the quadratic cost of standard attention. Doubling T creates four times as many pair scores before the value mix.
Scores only decide where to read. After scaling and masking, softmax turns each row into weights, and multiplying those weights by V produces one mixed value row for each position. The the row becomes a two-feature update that the heads will merge back into model width.
Scale before softmax
Scaled dot-product attention divides query-key scores by the square root of the head width before applying softmax:
Why divide by ? Consider the statistical distribution of raw scores. If the components of query and key are independent random variables with mean zero and variance one, their dot product is:
Each individual product has mean and variance:
Because the components are independent, their variances sum directly:
The standard deviation of the raw dot product grows with . For a typical head width or , raw dot products swing into magnitudes of to . Exponentiating numbers with spreads that large drives softmax into saturation: the largest entry gets a probability near , while the rest drop to zero. In that flat regime, softmax gradients vanish, halting learning.[1] Dividing by resets the score variance to , keeping gradients healthy.
Their base model used with heads, so each head had . The calculation below compares the unscaled and scaled versions of concrete scores [12.4, 9.1, 3.8]:
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 ]The unscaled row puts nearly all attention mass on one key, while the scaled row spreads it across visible choices. Scaling doesn't prevent a confident choice after training; it prevents head width alone from making early scores overly sharp.
Use the earlier the row to finish one attention read. Its raw scores [0.2, 1.28, 0.96] become [0.141, 0.905, 0.679] after division by . Softmax gives weights about [0.21, 0.44, 0.35]; applying them to the three value rows produces approximately [0.34, 0.65]. This is a mixture of values, not a probability distribution over next tokens. The vocabulary prediction comes much later.
The causal mask prevents answer leakage
Training can process the positions in function returns the result in parallel within each layer, but position the is still responsible for predicting result. If its query can read the result representation, it sees the answer. A causal mask sets future scores to negative infinity before softmax; those entries receive probability zero. Setting them to zero wouldn't work: , so forbidden keys would still receive active attention weight. Using guarantees .
Predict the allowed cells before using the table: the the row should include function, returns, and the, but not result. The final row can see all four inputs because no later input is present in this example.
| Query position | Can read |
|---|---|
function | function |
returns | function, returns |
the | function, returns, the |
result | all four positions |
Here is a separate four-position score matrix so the forbidden the → result cell is present. It isn't the three-position dot-product example above.
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)
15assert np.all(weights[future] == 0.0)
16assert np.allclose(weights.sum(axis=-1), 1.0)
17
18print("masked weights:")
19print(np.round(weights, 3))
20print("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 output shows future attention mass: 0.0. Read row the (row index 2) as [0.067, 0.111, 0.821, 0.000]: its weights sum to one across the current and earlier positions, and the result column is zero. If a causal language model reports nonzero future attention during training, it has a data-leak bug, not an impressive loss curve. Check this invariant before celebrating a low training loss.
Why can't the representation at the attend to result while training a decoder-only next-token model?
Answer
result is the target token the the position is supposed to predict. Letting the 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
A single attention head only computes one weighted average across the sequence. But a token often needs to attend to multiple different relationships at once: one head might track syntactic dependencies (matching a verb to its subject function), while another tracks typing or semantic scoping, and a third tracks the immediate prior token.
Multi-head attention gives a block several such views: it projects model features into heads, applies attention independently in each head, concatenates their outputs, and projects back to d_model.[1] Each projection can use every input feature. Splitting the projected coordinates doesn't restrict head 1 to reading only the first half of the original embedding.
Suppose our tiny model width is four and it has two heads. Each head owns two features. Predict the shape trace before running the cell: [1, 3, 4] should become [1, 2, 3, 2] after the split, then return to [1, 3, 4] after the merge. Splitting and merging must preserve every token position:
1import numpy as np
2
3batch, tokens, d_model, heads = 1, 3, 4, 2
4assert d_model % heads == 0
5d_head = d_model // heads
6projected = np.arange(batch * tokens * d_model).reshape(batch, tokens, d_model)
7
8split = projected.reshape(batch, tokens, heads, d_head).transpose(0, 2, 1, 3)
9merged = split.transpose(0, 2, 1, 3).reshape(batch, tokens, d_model)
10assert np.array_equal(projected, merged)
11
12print("all-heads shape:", split.shape)
13print("merged shape:", merged.shape)
14print("merge restores values:", bool(np.array_equal(projected, merged)))1all-heads shape: (1, 2, 3, 2)
2merged shape: (1, 3, 4)
3merge restores values: TrueThe head axis describes independent work inside one block. The block axis is sequential depth: output from block 1 enters block 2. Stacking repeats the structure, not the weights; each block has its own projections. Mixing heads with blocks loses that dependency.
A block alternates communication and local transformation
Attention lets token positions exchange information across sequence steps. The next sublayer changes each row on its own, using the same weights at every position. That feed-forward network (FFN) is where a token's mixed context gets a per-position nonlinear transformation. Geva et al. observed that FFN layers act like key-value memories: the first linear projection detects input patterns, while the second projection writes out factual associations.[6]
The original Transformer used two linear layers with a ReLU nonlinearity between them, widening from to (a expansion) and back:[1]
The first projection widens the feature dimension; the second returns it to d_model, which keeps blocks stackable. This isolated FFN demo feeds in our positioned vectors to show [3, 4] -> [3, 6] -> [3, 4]. In a complete pre-LN block, the FFN instead receives the normalized stream after attention's residual addition. Bias terms are omitted here so the matrix sizes stay visible.
1import numpy as np
2
3x = np.array([
4 [1.00, 0.00, 0.40, 0.00],
5 [0.25, 1.00, 0.50, 0.05],
6 [0.10, 0.30, 0.80, 1.10],
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.346 0.496 0.407 0.517]Unlike attention's Q @ K.T, neither FFN multiplication creates a token-by-token matrix. Every row gets the same transformation without reading another row.
Modern production models such as Llama, Mistral, and Gemma replace standard MLPs with SwiGLU (Swish Gated Linear Unit):[7][5]
Here, (also called SiLU). Instead of a single upward projection, two linear projections run in parallel: one acts as a smooth gating filter that scales the linear path elementwise before projecting back down through . Because SwiGLU introduces a third weight matrix, implementations typically size the intermediate dimension to (rounded to multiples of 64 or 256) so total parameter counts match a standard MLP.
Residual paths and normalization
A residual connection adds a sublayer result back to its input. Because attention and the FFN return to d_model, addition keeps the running stream at the same shape. It creates an identity path through deep stacks, following the residual-network concept from He et al.[8]
Placement of normalization determines training stability:
- Post-LayerNorm (2017 original): Normalization sits directly in the residual stream after each addition:[1] As backpropagation flows backward from layer to layer , gradients pass repeatedly through the LayerNorm Jacobian at every block. In deep networks (), gradients vanish or explode near early layers unless trained with delicate learning-rate warmup schedules.
- Pre-LayerNorm (modern standard): Normalization precedes each sublayer, leaving the main residual addition untouched:[9][10] The full recurrence becomes an additive telescope: . Differentiating with respect to an early layer produces: The identity matrix acts as an unobstructed gradient highway, letting error signals travel directly across all layers without decaying. That's why Pre-LN models train reliably without warmups.
Architectures also differ in the normalization statistic itself:
- LayerNorm: Centers by subtracting mean , then scales by standard deviation with learned scale and shift .[11]
- RMSNorm: Zhang & Sennrich showed that mean-centering is computationally unnecessary; stability comes almost entirely from scaling by the root-mean-square: Skipping mean subtraction saves memory bandwidth in GPU kernels, providing a 10% to 50% speedup in normalization runtime while matching LayerNorm's training stability. Llama and Mistral standardise on RMSNorm.[12][5]
Before running the snippet, predict two checks: normalization should put each row near mean 0 and variance 1, while adding the update should leave the residual output at shape [2, 4]. The code simulates that pre-normalization order with a small 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)The small eps keeps division defined even for a constant row. It also explains why the variances are slightly below one. Learned scale and offset can change those statistics afterward; LayerNorm doesn't promise every output always has mean zero and variance one. In the block view, follow the attention update into x₁, then the FFN update into x₂. Both additions preserve [3, 4].

Implement masked attention in PyTorch
The NumPy example exposed the mask entry by entry. Now keep the same contract in one causal multi-head self-attention layer. The projections are learned linear maps; the mask is created from token positions. Calling masked_fill enforces that visibility constraint: it turns every future score into negative infinity before softmax.
Trace the shapes before running it: [1, 4, 8] becomes [1, 2, 4, 4] for each of q, k, and v; scores and weights stay [1, 2, 4, 4]; merging heads returns [1, 4, 8]. The largest future weight should be exactly zero.
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 if heads <= 0 or d_model % heads != 0:
9 raise ValueError("heads must be positive and divide d_model")
10 self.heads = heads
11 self.d_head = d_model // heads
12 self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
13 self.out = nn.Linear(d_model, d_model, bias=False)
14
15 def forward(self, x):
16 batch, tokens, d_model = x.shape
17 q, k, v = self.qkv(x).chunk(3, dim=-1)
18 def split_heads(tensor):
19 return tensor.view(batch, tokens, self.heads, self.d_head).transpose(1, 2)
20 q, k, v = map(split_heads, (q, k, v))
21
22 scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_head)
23 future = torch.triu(
24 torch.ones(tokens, tokens, dtype=torch.bool, device=x.device),
25 diagonal=1,
26 )
27 weights = torch.softmax(scores.masked_fill(future, float("-inf")), dim=-1)
28 mixed = weights @ v
29 merged = mixed.transpose(1, 2).contiguous().view(batch, tokens, d_model)
30 return self.out(merged), weights
31
32torch.manual_seed(4)
33x = torch.randn(1, 4, 8)
34attention = CausalSelfAttention()
35output, weights = attention(x)
36future = torch.triu(torch.ones(4, 4, dtype=torch.bool), diagonal=1)
37assert torch.all(weights[0, :, future] == 0)
38assert torch.allclose(weights.sum(dim=-1), torch.ones_like(weights.sum(dim=-1)))
39
40print("attention output shape:", tuple(output.shape))
41print("weights shape:", tuple(weights.shape))
42print("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, and the [batch, heads, tokens, tokens] weight tensor exposes each head's routing decisions. The zero future weight is the causal invariant in PyTorch form.
The PyTorch cell uses random inputs to test shapes and visibility separately from the hand-worked values. It deliberately omits padding, dropout, and a KV cache. All positions are real tokens, so the causal diagonal always supplies at least one visible key. Modern production stacks use fused kernels such as torch.nn.functional.scaled_dot_product_attention, which compute this exact causal calculation without materializing the full attention matrix in GPU memory.
Assemble one decoder block
Attention now returns a same-width update, so it can sit inside a full pre-normalized block. Read the sequence before the code: normalize, attend, add; normalize, widen through the FFN, return to width eight, and add again. For input [2, 4, 8] and d_ff=24, every residual merge should still be [2, 4, 8].
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, the same activation family as GPT-2's MLP. GPT-2 uses a tanh approximation; nn.GELU() below uses the default exact formulation. Either way, the FFN's structural role stays the same: widen, transform, and return to d_model.[13][9]
Continue in the same Python session so CausalSelfAttention remains defined. The attention module returns both its update and its weights; the block needs only the update for the residual addition.
1class DecoderBlock(nn.Module):
2 def __init__(self, d_model=8, heads=2, d_ff=24):
3 super().__init__()
4 self.norm1 = nn.LayerNorm(d_model)
5 self.attention = CausalSelfAttention(d_model, heads)
6 self.norm2 = nn.LayerNorm(d_model)
7 self.ffn = nn.Sequential(
8 nn.Linear(d_model, d_ff),
9 nn.GELU(),
10 nn.Linear(d_ff, d_model),
11 )
12
13 def forward(self, x):
14 update, _ = self.attention(self.norm1(x))
15 x = x + update
16 x = x + self.ffn(self.norm2(x))
17 return x
18
19torch.manual_seed(5)
20x = torch.randn(2, 4, 8, requires_grad=True)
21block = DecoderBlock()
22output = block(x)
23output.square().mean().backward()
24assert output.shape == x.shape
25assert x.grad is not None and torch.isfinite(x.grad).all()
26
27print("input -> output:", tuple(x.shape), "->", tuple(output.shape))
28print("input gradient is finite:", bool(torch.isfinite(x.grad).all()))1input -> output: (2, 4, 8) -> (2, 4, 8)
2input gradient is finite: TrueThe finite-gradient check catches a broken backward pass for this input; it doesn't prove that a deep stack will train well. The block still hasn't generated a word. 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
The block stack has finished its same-width work. The vocabulary head changes the stream's output width from model features to token scores. GPT-2 applies a final ln_f normalization immediately before computing logits.[9]
A learned output matrix converts each hidden vector into one raw score per vocabulary item. These scores are logits. Vaswani et al. share that matrix with the token embedding table; GPT-2's wte is reused the same way. This strategy, called weight tying, halves the parameter footprint of the vocabulary layers and enforces geometric consistency between input representations and output predictions.[1][2] The toy below keeps a separate output_weight so the multiply is visible.
Now connect the actual prompt to the block. Continue in the same session with the DecoderBlock class above. This time use width four, two heads, and an FFN hidden width of six. The embedding table is exactly the one from the opening NumPy example.
The matrix multiply applies the vocabulary head to every position: [1, 3, 4] @ [4, 6] → [1, 3, 6]. Generation selects the last six scores. Training can use all three rows, with a shifted next-token target for each. The output weights below are deliberately chosen for a readable ranking; the random block hasn't learned English.
1vocab = ["function", "returns", "the", "result", "None", "value"]
2ids = torch.tensor([[0, 1, 2]])
3embedding_table = torch.tensor([
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 [0.1, 0.5, 1.0, 0.9],
8 [0.0, 0.1, 0.0, 0.1],
9 [0.2, 0.0, 0.4, 0.5],
10])
11position = torch.tensor([
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])
16output_weight = torch.tensor([
17 [1.0, 0.2, 0.0, 0.0, 0.0, 0.0],
18 [0.0, 0.0, 0.0, 0.0, -1.0, 0.0],
19 [0.0, 0.0, 0.0, 0.5, 0.0, 1.0],
20 [0.0, 0.0, 0.2, 1.0, 0.0, 0.0],
21])
22
23torch.manual_seed(7)
24toy_block = DecoderBlock(d_model=4, heads=2, d_ff=6).eval()
25final_norm = nn.LayerNorm(4).eval()
26with torch.no_grad():
27 positioned = embedding_table[ids] + position
28 contextual = toy_block(positioned)
29 normalized = final_norm(contextual)
30 logits = normalized @ output_weight
31 probabilities = logits[0, -1].softmax(dim=-1)
32 best = probabilities.argmax().item()
33
34assert logits.shape == (1, 3, len(vocab))
35assert not torch.allclose(contextual, positioned)
36assert torch.allclose(probabilities.sum(), torch.tensor(1.0))
37
38print("last row after block:", [round(v, 3) for v in contextual[0, -1].tolist()])
39print("normalized last row:", [round(v, 3) for v in normalized[0, -1].tolist()])
40print("all-position logits shape:", tuple(logits.shape))
41print("last-position logits:", [round(v, 3) for v in logits[0, -1].tolist()])
42print("top token:", vocab[best])
43print("top probability:", f"{probabilities[best]:.3f}")1last row after block: [0.037, 0.267, 0.994, 0.87]
2normalized last row: [-1.26, -0.686, 1.128, 0.818]
3all-position logits shape: (1, 3, 6)
4last-position logits: [-1.26, -0.252, 0.164, 1.382, 0.686, 1.128]
5top token: result
6top probability: 0.353The vector sent to the output head has passed through attention, both residual additions, the FFN, and final normalization. Its values differ from the original embedding. The figure drops the batch axis of size one and shows the last-position slice; slicing before or after the position-wise vocabulary projection gives the same six logits.
![End-to-end tensor shape pipeline and final vocabulary projection. Tensors flow from input IDs [1, 3] to embeddings [1, 3, 4], multi-head QKV [1, 2, 3, 2], attention weights [1, 2, 3, 3], FFN expansion [1, 3, 6], and normalized stream [1, 3, 4]. The final row [-1.260, -0.686, 1.128, 0.818] dots with the result column [0, 0, 0.5, 1] to yield winning logit 1.382.](/cdn/content-image/preparation/transformer-architecture-end-to-end/illustrations/_generated/shape_trace_pipeline_dark.png?v=fdf05b37666f)
Choosing the largest logit is greedy decoding. It appends result to make function returns the result.
Alternatively, sampling introduces temperature scaling to soften or sharpen the logits before softmax:
When , the probabilities collapse into an argmax choice. At , the model samples from its natural probability distribution. When , probabilities flatten toward a uniform distribution, producing diverse continuations.
To run another step, add a fourth row to the positional table as well as appending the new token ID. Our three-position demo isn't an unlimited text generator. Optimized serving reuses earlier attention keys and values through a KV cache instead of recomputing them for every new token.[14] We haven't implemented that optimization here. The next lesson turns this single step into a generation loop and a training objective.
Encoder, decoder, and encoder-decoder visibility
Decoder-only generation is one member of the Transformer family. Once the job changes, the visibility rule must change with it: classification can use the whole input, while translation needs a full source plus a causal target. Compare which positions each variant may read:
| Variant | Visibility rule | Suitable lesson example | Representative source |
|---|---|---|---|
| Encoder-only | Each input position can read both left and right context | Classify a complete snippet as Python code or prose | BERT[3] |
| Decoder-only | Each position can read only itself and prior tokens | Generate the next token of function returns the | GPT, Llama[2][5] |
| Encoder-decoder | Encoder reads source; decoder reads source plus prior output tokens | Translate a short docstring from English to another language | T5[15] |

Why did decoder-only models come to dominate modern LLMs? Four architectural properties drove that outcome:
- Unified pre-training objective: Autoregressive next-token prediction turns all text into an unsupervised training stream. Unlike masked language models that corrupt 15% of tokens or sequence-to-sequence models requiring paired data, every single token in the sequence provides a training loss signal.
- Zero-cost prompt conditioning: In-context prompting and few-shot examples require no custom architecture. Prompts simply precede generation in the causal sequence, letting the model condition on instructions naturally through attention history.
- Streamlined KV caching: During autoregressive decoding, a decoder-only model maintains a single KV cache for its causal self-attention layers. Encoder-decoder models must maintain two distinct caches per layer: a static cross-attention cache over encoder states and a growing causal self-attention cache over generated tokens.
- Predictable scaling behavior: Empirical scaling laws demonstrated that autoregressive decoders scale smoothly across compute, parameters, and tokens, unlocking emergent reasoning without architectural rework.
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 source sequence, so its core path is causal self-attention.
Diagnose a broken forward pass
When a forward pass fails, don't inspect every weight first. Ask which contract broke: visibility, shape, device placement, or normalization. The symptom usually narrows the search:
| 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 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 |
Manual softmax produces NaN | A row was fully masked, so subtracting its maximum computes | Give every real query a visible key; handle fully padded queries explicitly |
| Temperature scaling produces flat probabilities regardless of temperature setting | Temperature was applied after softmax instead of dividing raw logits | Divide logits by prior to exponentiation |
Use a deliberately bad mask to make leakage visible. Predict the two totals before running the cell: an unmasked matrix should assign positive mass to future cells, while the causal version should assign 0.000.
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 across rows:", f"{unmasked[future].sum():.3f}")
17print("masked future mass across rows:", f"{masked[future].sum():.3f}")1unmasked future mass across rows: 1.940
2masked future mass across rows: 0.000The unmasked total exceeds one because it adds probability mass from several separately normalized rows. Each row still sums to one. A zero future total is necessary, but inspecting the mask alone can miss leakage elsewhere in the block. A stronger test changes a future input and checks whether an earlier output changes.
Test the whole block, not just the mask
Continue the PyTorch session. Replace the final token's features while keeping the first two positions fixed. Which outputs may change? Causality says the first two must be unchanged, even after the FFN and residual additions. The last output should change, so the test also catches a block that ignores its input.
1changed = positioned.clone()
2changed[:, -1, :] = torch.tensor([2.0, -1.0, 0.0, 0.5])
3with torch.no_grad():
4 original_output = toy_block(positioned)
5 changed_output = toy_block(changed)
6
7prefix_unchanged = torch.allclose(original_output[:, :-1], changed_output[:, :-1])
8last_changed = not torch.allclose(original_output[:, -1], changed_output[:, -1])
9assert prefix_unchanged and last_changed
10print("earlier outputs unchanged:", prefix_unchanged)
11print("last output changed:", last_changed)1earlier outputs unchanged: True
2last output changed: TrueTry removing masked_fill in CausalSelfAttention. The earlier outputs should now change, causing this test to fail. Keep dropout disabled for this comparison; otherwise random dropout masks can change outputs even when causality is correct.
You double the prompt from 3 tokens to 6, keeping width 4 and two heads. Which tensors grow fourfold, and which only double?
Answer
Each head's score and attention-weight matrices grow from [3,3] to [6,6], four times as many entries. Hidden states, Q/K/V, and FFN activations double their token dimension. Model parameter shapes don't change. This describes the explicit full matrices in our implementation, not every optimized attention kernel's memory use.
You replace the FFN's GELU with no activation at all. The code still preserves shape. What capability disappears from that sublayer?
Answer
Two consecutive linear transformations without an intervening nonlinearity collapse mathematically into one linear map (). The intermediate expansion alone doesn't provide nonlinear per-token transformation. The overall Transformer remains nonlinear because attention and normalization remain, but the FFN loses its capacity to model complex feature interactions.
The attention output is [1,3,4], but you change the FFN's final projection to width 6. Why can't the residual addition be repaired just by reshaping?
Answer
The residual has 12 entries and the FFN update has 18. More importantly, addition must match the same token and feature coordinates. Project the six-wide update back to four features with a learned map; don't reinterpret token or feature axes to hide the mismatch.