Decoder-only transformers: the GPT family
Trace a decoder-only language model from token ids to next-token logits through causal masking, and walk through GPT-2's parameter counts.
The Transformer was originally designed with both an encoder and a decoder for machine translation. Decoder-only models (the GPT family) discard the encoder entirely: stacked transformer blocks with causal masking, optimized by next-token prediction on text. This minimal design has become the dominant architecture for large language models[1].
Architecture overview
Three phases:
- Embed: token ids -dimensional embeddings via . Add positional encodings.
- Transform: identical blocks, each with pre-norm, causal self-attention, and FFN.
- Unembed: final LayerNorm, then project to logits: , where the embedding matrix is typically weight-tied to the output projection (halves parameter count).
Causal masking
Every layer uses a causal (lower-triangular) attention mask: position attends only to positions . This ensures the model cannot "see" future tokens when predicting the next one, making the next-token prediction objective valid.
In practice, attention is implemented with full matrices and the upper triangle is set to before softmax (as in the self-attention lesson), not with sparse indexing.
GPT-2 parameter count
GPT-2 small: , heads, , blocks, .
| Component | Formula | Count |
|---|---|---|
| Token embedding | M | |
| Positional embedding | M | |
| Per-block attention () | M | |
| Per-block FFN | M | |
| Per-block LN () | negligible | |
| Total per block | M | |
| 12 blocks | M | |
| Total (weight-tied output) | M |
The embedding matrix dominates for small vocab; the FFN blocks dominate for large .
Training objective
Next-token prediction (causal language modeling):
Every token in a sequence is a training target simultaneously (unlike RNNs, which must compute one forward pass per position serially). This parallelism is why Transformers replaced RNNs for large-scale pretraining.
Forward pass in numpy
import numpy as np
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
def layer_norm(x, g, b, eps=1e-5):
mu = x.mean(-1, keepdims=True)
std = x.std(-1, keepdims=True)
return g * (x - mu) / (std + eps) + b
def causal_attention(Q, K, V):
T, dk = Q.shape
scores = Q @ K.T / np.sqrt(dk)
mask = np.triu(np.ones((T, T)), k=1).astype(bool)
scores[mask] = -1e9
return softmax(scores) @ V
class TinyGPT:
def __init__(self, V, d, n_heads, d_ff, n_layers, T_max, seed=0):
rng = np.random.default_rng(seed)
s = 0.02
self.We = rng.normal(0, s, (V, d))
self.Wp = rng.normal(0, s, (T_max, d))
self.layers = []
for _ in range(n_layers):
self.layers.append({
"Wq": rng.normal(0, s, (d, d)),
"Wk": rng.normal(0, s, (d, d)),
"Wv": rng.normal(0, s, (d, d)),
"Wo": rng.normal(0, s, (d, d)),
"W1": rng.normal(0, s, (d_ff, d)),
"W2": rng.normal(0, s, (d, d_ff)),
"g1": np.ones(d), "b1": np.zeros(d),
"g2": np.ones(d), "b2": np.zeros(d),
})
self.gf = np.ones(d); self.bf = np.zeros(d)
self.d, self.n_heads = d, n_heads
def forward(self, ids):
T = len(ids)
x = self.We[ids] + self.Wp[:T] # (T, d)
h = self.d // self.n_heads
for layer in self.layers:
# Pre-norm attention
xn = layer_norm(x, layer["g1"], layer["b1"])
Q = xn @ layer["Wq"]; K = xn @ layer["Wk"]; V = xn @ layer["Wv"]
# Multi-head (simplified: single head for readability)
attn_out = causal_attention(Q, K, V) @ layer["Wo"]
x = x + attn_out
# Pre-norm FFN
xn = layer_norm(x, layer["g2"], layer["b2"])
ff = np.maximum(0, xn @ layer["W1"].T) @ layer["W2"].T
x = x + ff
x = layer_norm(x, self.gf, self.bf)
return x @ self.We.T # (T, V) logits (weight-tied)
def loss(self, ids):
logits = self.forward(ids[:-1])
targets = ids[1:]
log_probs = np.log(softmax(logits))
return -log_probs[np.arange(len(targets)), targets].mean()
V, d, h, dff, L, Tmax = 100, 32, 4, 128, 2, 64
model = TinyGPT(V, d, h, dff, L, Tmax)
ids = np.array([5, 12, 3, 99, 44, 7])
print(f"Loss: {model.loss(ids):.4f} (random init ≈ {np.log(V):.4f})")Untrained, the loss should be close to (uniform over the vocabulary).
GPT family progression
| Model | Params | Training tokens | |||
|---|---|---|---|---|---|
| GPT-2 (small) | 768 | 12 | 50K | 117M | 40B |
| GPT-3 | 12,288 | 96 | 50K | 175B | 300B |
| LLaMA-2 70B | 8,192 | 80 | 32K | 70B | 2T |
| LLaMA-3 70B | 8,192 | 80 | 128K | 70B | 15T |
The architecture is nearly identical across the family; gains come from scale (parameters and tokens) and improved data quality.
Exercises
Exercise 1. GPT-2 small ties its output projection to the token embedding . Using and , compute the parameter count of , and state how many parameters the tie saves versus an untied output head of the same shape.
Solution
Step 1. The embedding matrix has parameters, about M.
Step 2. An untied output head would be a second matrix of the same shape, another M parameters. Weight tying reuses for the unembed, so it saves the full M, roughly a third of the model's M total. This is why the embedding dominates the budget at small vocab.
Exercise 2. A decoder-only block applies pre-norm causal attention then a pre-norm FFN, both with residual connections. For width and , count the parameters in one block's four attention projections () and its two FFN matrices (ignore biases and LayerNorm). Which dominates?
Solution
Step 1. Each of is , so the attention block holds parameters, about M.
Step 2. The FFN has and , so parameters, about M.
Step 3. The FFN holds twice the attention count. At fixed , the expansion makes the FFN the dominant per-block cost.
Exercise 3 (code). Build the causal attention pattern for a length- sequence and verify two structural facts: every attention row is a valid distribution (sums to one), and row places zero weight on any future position , so position attends to exactly positions.
Solution
import numpy as np
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
rng = np.random.default_rng(0)
T, dk = 5, 4
Q = rng.standard_normal((T, dk))
K = rng.standard_normal((T, dk))
scores = Q @ K.T / np.sqrt(dk)
mask = np.triu(np.ones((T, T)), k=1).astype(bool) # strictly-future positions
scores[mask] = -1e9
A = softmax(scores)
assert np.allclose(A.sum(axis=1), 1.0) # each row is a distribution
assert np.allclose(A[mask], 0.0, atol=1e-6) # no leakage to the future
nonzero_per_row = (A > 1e-6).sum(axis=1)
assert np.array_equal(nonzero_per_row, np.arange(1, T + 1))
print("attended positions per row:", nonzero_per_row)Row has exactly nonzero weights, the causal cone that makes next-token prediction a valid objective.
The next lesson introduces encoder-only transformers (BERT) which use bidirectional attention for understanding tasks rather than generation.
References
- [1]Radford, A., Wu, J., Child, R., Luan, D., Amodei, D., & Sutskever, I. (2019). "Language models are unsupervised multitask learners (GPT-2)". OpenAI.↩