Decoder-Only Transformers and Deep-Learning Foundations
machine-learning
language-models
transformers
Build causal self-attention, multi-head projections, LayerNorm, and a pre-norm residual block in NumPy before checking the reusable PyTorch decoder.
A fixed-context MLP combines a predetermined neighborhood of tokens. A decoder-only Transformer learns how to mix every earlier position while preserving the autoregressive boundary. The NumPy cells keep the attention equations and axis conventions visible; the cumulative PyTorch implementation then packages the same contracts into the ProofLM decoder used by training and evaluation.
Shape checks, a future-token intervention, a local gradient check, and a depth experiment make the architectural contracts observable before the reusable model is trusted.
Self-attention
Let X\in\mathbb{R}^{T\times d} contain one hidden vector per position. Learned projections produce
Q=XW_Q,\qquad K=XW_K,\qquad V=XW_V,
and scaled dot products produce a score from query position i to key position j:
S_{i,j}=\frac{Q_iK_j^\top}{\sqrt{d_k}}.
The causal condition is a lower-triangular visibility matrix. Set the scores for j>i to -\infty before the row-wise softmax. The resulting weights sum to one over visible keys, and the weighted values are the information passed to each query position.
Start with one head and one sequence. That version isolates the mask from the bookkeeping needed by multiple heads.
output shape: (5, 6)
attention weights at the last position: [0.276 0.127 0.206 0.256 0.135]
future-token mask test: earlier positions unchanged
The future-token intervention changes the query, key, and value at the last position, but earlier rows remain identical because their visible key set ends at their own position. The zero upper triangle is the direct causal invariant. A row-wise softmax is essential: normalizing across all scores would allow a future position to receive nonzero weight even after masking.
Multi-head attention
With h heads and width d, choose d_h=d/h. The same projected sequence is reshaped from (T,d) to (h,T,d_h), and each head computes its own score matrix. Concatenate the head outputs back to (T,d) and apply an output projection. The split does not create more information by itself; it gives different subspaces separate attention weights.
Keep the head axis first in the next implementation. This makes the score contraction explicit and gives shape assertions a single convention.
The output projection restores the model width after the heads are concatenated. The shape assertions catch three common mistakes: treating the head axis as a batch axis, contracting keys along the wrong time dimension, and returning one output per head instead of one output per position. Each head still has a zero upper triangle, so splitting channels does not weaken the causal boundary.
Pre-norm residual blocks
For a hidden vector x\in\mathbb{R}^d, LayerNorm computes
where \mu and \sigma^2 are the mean and variance across the channel axis. It normalizes each position independently, so sequence length and other examples do not change the statistics of a position. The learnable \gamma and \beta restore a trainable scale and offset.
A pre-norm residual block applies normalization inside each branch:
The identity terms provide a direct path for both activations and derivatives. The feed-forward branch expands the channel width, applies a pointwise nonlinearity, and projects back to the model width.
The block preserves the (T,d) shape through both residual additions, while the feed-forward branch temporarily uses 4d channels. LayerNorm’s mean and variance checks are per position, not over the whole sequence. The output projection turns the final hidden states into one vocabulary logit vector per position; a later causal loss can compare position t with token t+1.
Attention backpropagation
A full Transformer backward pass combines matrix products, softmax, normalization, and the nonlinearity. A useful intermediate check isolates a projection that appears inside the complete forward path. If C is the merged attention context, O=CW_O, and U=\partial L/\partial O, then
\frac{\partial L}{\partial W_O}=C^\top U.
Compare this hand-derived slice with finite differences. Once this check passes, the same matrix-calculus rule can be composed with the gradient of the attention context.
def output_projection_loss_and_grad(context, output_projection, upstream): output = context @ output_projection loss =float(np.sum(output * upstream)) gradient = context.T @ upstreamreturn loss, gradientgrad_rng = np.random.default_rng(SEED +3)context = grad_rng.normal(size=(4, width))projection = grad_rng.normal(scale=0.2, size=(width, width))upstream = grad_rng.normal(size=(4, width))loss, analytic_gradient = output_projection_loss_and_grad(context, projection, upstream)epsilon =1e-5numeric_gradient = np.zeros_like(projection)for index in np.ndindex(projection.shape): original = projection[index] projection[index] = original + epsilon plus = output_projection_loss_and_grad(context, projection, upstream)[0] projection[index] = original - epsilon minus = output_projection_loss_and_grad(context, projection, upstream)[0] projection[index] = original numeric_gradient[index] = (plus - minus) / (2.0* epsilon)maximum_error = np.max(np.abs(analytic_gradient - numeric_gradient))print("maximum output-projection gradient error:", maximum_error)np.testing.assert_allclose(analytic_gradient, numeric_gradient, atol=1e-8, rtol=1e-6)
maximum output-projection gradient error: 2.1735502286901465e-11
The local gradient agrees at finite-difference precision. This test does not certify every derivative in the block; it certifies one interface that is easy to get wrong when head outputs are merged. The remaining attention and LayerNorm derivatives can be tested by applying the same finite-difference pattern to a small selected parameter set.
Depth and gradient flow
Backpropagation multiplies a Jacobian from every block. To isolate that multiplication, use a controlled linearized branch: each layer receives an orthogonal branch Jacobian with a prescribed gain. A residual block has Jacobian I+A; a plain stacked branch has Jacobian A. The normalized flag reduces the branch gain, representing the scale control that normalization provides around a typical operating point. This is not a replacement for the exact LayerNorm Jacobian; it is a small experiment for the path-length mechanism.
Measure the norm of a fixed upstream gradient after traversing increasing depth. Use the same random branch directions for all four configurations.
A training call usually carries a batch axis in addition to time and channel. The single-sequence function above is easy to inspect, so wrap it over a batch without changing its per-sequence mask. Assert every axis after stacking; a hidden broadcast over the batch axis would otherwise look like a valid matrix multiplication.
The batched wrapper preserves the order (batch, time, channel) for hidden states and (batch, head, time, time) for attention weights. Each batch item receives its own causal mask; no sequence can read another sequence because the loop keeps their score matrices separate. The assertion complements the earlier single-sequence checks rather than replacing them.
Reusable PyTorch decoder
The NumPy attention path exposes the mathematics and the causal intervention. The model artifact used by later chapters is ProofLM: it adds RoPE, tied embeddings, masked loss, generation, and state-dict serialization behind a stable forward interface. The checks below compare the same invariants at the module boundary.
/var/folders/jq/9vsvd9252_349lsng_5gc_jw0000gn/T/ipykernel_56589/3606104176.py:43: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.
Consider using tensor.detach() first. (Triggered internally at /Users/runner/work/pytorch/pytorch/torch/csrc/autograd/generated/python_variable_methods.cpp:823.)
print("masked loss:", round(float(masked_loss), 6))
The plain stack repeatedly multiplies a branch with gain below one, so its upstream signal shrinks rapidly. Residual addition retains an identity component, and reducing the branch gain keeps the residual Jacobian closer to an identity map across depth. The exact values depend on width and initialization; the robust mechanism is the additive identity path, while normalization controls the scale of the learned branch that is added to it.
Summary
Causal self-attention masks future keys before softmax, and a future-token intervention verifies the mask behavior directly.
Multi-head attention splits the channel axis, computes independent score matrices, and restores the model width after merging.
LayerNorm normalizes channels per position; the pre-norm residual block preserves shape and creates a direct gradient path.
A hand-derived output-projection gradient agrees with finite differences, providing a local backward check for the block.
A controlled depth experiment shows why residual paths and branch-scale control affect gradient propagation.
Chapter 05 places a small causal model in an end-to-end training loop with validation, checkpoints, and reproducible resumption.
Exercises
Use the exercises to test the chapter’s invariants and connect the derivations to the reusable implementation. Solutions are hidden in the notebook source and are available through the course tooling when needed.
[P4.1] Causal intervention
Causal intervention. Describe a test that changes only the final hidden state and verifies that attention outputs at earlier positions do not change. State why the same test would fail without a causal mask.
[P4.2] Residual-depth gradient
Residual-depth analysis. Explain the difference between the Jacobian of a plain branch and a residual branch. Use the depth experiment to identify which configuration keeps the upstream gradient closer to its initial norm and why.
[P4.3]
Decoder invariants. A serialized decoder must preserve its output contract. List three checks for a causal decoder module covering (1) future-token visibility, (2) parameter sharing, and (3) save/load identity. Then state why the first check is stronger than inspecting only the triangular attention weights.
def decoder_checks(model, original_logits, changed_logits, reloaded_logits):# Return three booleans for the requested module contracts.pass