Verified Deep Learning with Lean 4

9 Vision Transformer

Where Part 1 has been heading

Every previous chapter has introduced a new architectural primitive and proved its backward pass. Each one slotted into the VerifiedNetSpec type and earned its own has_vjp theorem, and that includes convolution, BatchNorm, residual, depthwise, SE, LayerNorm and GELU. This chapter does the same for attention, but it also does something larger: it composes every layer the framework has proved so far into one machine-checked backward pass over a complete state-of-the-art image classifier.

The destination is Theorem 84 at the end of the chapter. It says, roughly: the full ViT backbone is a \(\mathsf{HasVJPMat}\), and that backbone is the patch embedding, twelve transformer blocks and a final LayerNorm. The proof composes everything this chapter introduces, plus most of what the previous chapters introduced, into a single tower of vjpMat_comp applications. Read the proof’s dependency list and you see a tour of Part 1: dense, residual, layer norm, GELU, softmax, matrix multiply, plus the eight or ten matrix-machinery lemmas this chapter sets up. None of them is new in its own right. What’s new is how high the composition rule can be pushed.

9.1 Run it first

Before any of the attention mathematics, train the network that needs it. Four commands, and the last network in Part 1 is also the quickest one in it:

lake exe cache get                        # Mathlib oleans, ~30 s
./download_imagenette.sh                  # 330 MB dl, 2.4 GB unpacked
lake build vit-verified-adam
./.lake/build/bin/vit-verified-adam data

On one RTX 4060 Ti (CUDA 12.9), from runs/2026-08-12-vit-imagenette-xla-cuda/. XLA’s startup banner is removed, the network description line is wrapped, the per-step lines are dropped, and the middle of the run is elided:

[pjrt_ffi] XLA backend: PJRT 0.112, 1 device(s)
[pjrt_ffi] compiled verified_mlir/vit_adam_train_step.mlir
             (@vit_adam_train_step, 603 outputs, 1 replica)
             in 6104 ms
[pjrt_ffi] compiled verified_mlir/vit_fwd.mlir
             (@vit_fwd, 1 outputs, 1 replica) in 1644 ms
ViT-Tiny on Imagenette 224² (patch-16 → CLS+pos → 12 transformer
  blocks @ dim192/3heads/MLP768 → final LN → CLS-head 10)
  via the VERIFIED renderer → XLA/PJRT → GPU
  train 9469, val 3925; bs 32, ViT-Tiny adam
    (cosine+warmup 5ep, baseLR 0.000300), He init
[pjrt_ffi] RESIDENT: @vit_adam_train_step holds 600 parameter
             tensors (63.2 MB) on 1 device; they stop crossing PCIe
             from here
Epoch 1/80: loss=2.127541 lr=0.000060
  epoch 1: val_acc = 1381/3925 = 35.184713%  top5 = 3121/3925 = 79.515924%
Epoch 2/80: loss=1.869707 lr=0.000120
  epoch 2: val_acc = 1619/3925 = 41.248408%  top5 = 3350/3925 = 85.350318%
Epoch 3/80: loss=1.750333 lr=0.000180
  epoch 3: val_acc = 1591/3925 = 40.535032%  top5 = 3362/3925 = 85.656051%
Epoch 30/80: loss=1.004060 lr=0.000225
  epoch 30: val_acc = 2571/3925 = 65.503185%  top5 = 3698/3925 = 94.216561%
Epoch 79/80: loss=0.515797 lr=0.000000
  epoch 79: val_acc = 2698/3925 = 68.738854%  top5 = 3551/3925 = 90.471338%
Epoch 80/80: loss=0.515159 lr=0.000000
  epoch 80: val_acc = 2698/3925 = 68.738854%  top5 = 3549/3925 = 90.420382%
done (trained ViT-Tiny adam + cosine/warmup via packed threading).

Eighty epochs at about 18 seconds each, twenty-four minutes in total, and 68.74% top-1 with 90.42% top-5 on Imagenette’s 3,925-image validation split. The best epoch reached \(68.92\% \). That is the fastest network in this book’s Imagenette column by a wide margin and also the least accurate one in it, and §9.4 is about why both halves of that sentence are the same fact.

Two compile lines, and the second one is the whole eval story. Like ConvNeXt and unlike the three BatchNorm chapters, ViT emits no vit_fwd_eval. LayerNorm computes its statistics from the activation in front of it every time, so there is nothing to freeze and the training forward and the evaluation forward are the same function.

The top-5 column goes the wrong way, and that is the chapter’s result in miniature. Top-1 climbs the whole run, 35.2 to 68.7. Top-5 climbs to \(94.88\% \) at epoch 25 and then falls, finishing 4.5 points down at \(90.42\% \). On a ten-class problem top-5 is a weak question, so what that divergence measures is a network growing more confident about its top choice while its ranking of the remaining classes decays. A transformer with 5.5M parameters and 9,469 training images has enough capacity to memorize its way to a better top-1 and not enough signal to keep the tail honest, which is §9.4’s data-hunger argument showing up inside a single run rather than across the comparison table.

Attention is where you would expect this to break. That run did not execute a reimplementation of the network this chapter describes. It executed verified_mlir/vit_adam_train_step.mlir, which is pretty(provenGraph) off the renderer. Every softmax in it backpropagates by Theorem 66, every scaled dot-product attention by §§6870, every LayerNorm by Chapter 8’s, every GELU by that chapter’s, and every residual skip by Chapter 5’s additive fan-in.

And the fold holds at every depth. vitForward2_has_vjp chains the patch embedding, all twelve transformer blocks, the final LayerNorm and the head through vjp_comp, with vitForward2_has_vjp_correct carrying it back to the forward the spec denotes, and vitForwardKV_has_vjp generalizes it to depth \(k\). Its only hypothesis is \(\varepsilon {\gt} 0\) at the LayerNorms. It is a single global HasVJP over the entire network for the same reason Chapter 8’s is — softmax, GELU and LayerNorm are all smooth — so ViT gets the strong sentence too: every operation in the graph that just trained carries a proved backward, and their composition into the whole network carries one too.

Attention in one equation

A transformer encoder block does two things to its input sequence \(X \in \mathbb {R}^{N \times d}\) (think of \(N\) image patches, each represented as a \(d\)-vector):

  1. Multi-head self-attention (MHSA): each output row is a weighted average of all input rows, where the weights are computed from the input itself.

  2. Per-token MLP: a small two-layer MLP applied independently to every row.

Both halves are wrapped in a LayerNorm + residual sandwich (exactly the same template as ConvNeXt’s blocks from Chapter 8, and the layout that transferred to convnets in 2022 actually came from transformers in 2017).

The interesting half is the first one. Scaled dot-product attention computes, for queries \(Q\), keys \(K\), and values \(V\) (all linear projections of \(X\)):

\[ \mathrm{Attention}(Q, K, V) \; =\; \mathrm{softmax}\! \left(\frac{Q K^\top }{\sqrt{d_h}}\right) V. \]

In words: every row of \(Q\) is compared to every row of \(K\) via inner product (the \(QK^\top \) matrix is \(N \times N\), one entry per query-key pair). Each row of that \(N \times N\) matrix is passed through softmax to get a probability distribution over the keys (which positions to attend to). Those probabilities then weight the rows of \(V\) to produce the output. The \(1 / \sqrt{d_h}\) scaling keeps the softmax from saturating at large \(d_h\). Multi-head attention just runs several copies of this in parallel on lower-dimensional projections, concatenating the outputs.

Each token, meaning each row of \(X\), ends up looking at every other token. That’s the qualitative break from CNNs: convolutions look only at a local neighborhood, attention looks everywhere. The receptive field is global from the first layer.

Why this chapter has thirty theorems

The math of attention is matrix algebra. Chapter 1’s foundation rules (chain, sum, product, identity) were stated and proved for vector functions. Before we can prove backward passes through \(Q K^\top \) and the row-wise softmax, we need the matrix-level lifts of those rules: chain rule on matrices, additive fan-in on matrices, identity on matrices, plus matrix-specific moves like transpose, matmul-with-one-factor-fixed, and scalar scale. That’s the first section of theorems below (Matrix-level machinery, \(\sim \)15 theorems). Every one of them is a mechanical lift of a vector-level theorem from Chapter 1, proved via the row-projection \(\mathrm{ContinuousLinearMap}\) and the existing chain rule. Pay the cost once, reuse forever.

The second section (Attention proofs, \(\sim \)15 theorems) applies the matrix machinery to the actual attention math. Row-wise softmax (a per-row softmax applied to the \(N \times N\) score matrix) has the well-known closed-form Jacobian \(p_i(\delta _{ij} - p_i)\), and we prove the row-wise lifting that says this holds across matrices. Scaled dot-product attention’s backward decomposes into three matmul-backward applications (one for each of \(Q\), \(K\), \(V\)) plus the row-wise softmax backward, and we prove each. Then multi-head, then the attention sublayer (with residual), then the MLP sublayer (with residual), then a single transformer block, then a \(k\)-deep transformer tower, then the full ViT body. Each step is one or two compositions of the previous step. The chain that started at vjp_comp in Chapter 1 reaches all the way to a 5.5M-parameter image classifier here.

What’s actually proved

That’s the framework-side payoff of this chapter: every architectural piece used by any Part-1 network has a machine-checked backward. Once we land Theorem 84, the sentence “the gradient computed by this trainer is the mathematically correct one” is no longer a hope or a folklore result. It is a theorem the Lean kernel verifies on every build.

The empirical-side payoff comes after the theorems, in the Example section: ViT-Tiny trained on Imagenette underperforms the ConvNets, fairly dramatically, because 9,469 training images is too small for a transformer’s lack of inductive bias. That’s informative on its own terms: the data-hungry-transformer story made concrete.

9.2 Matrix-level machinery

Attention is fundamentally matrix algebra: queries times keys transposed (\(QK^T\)), softmax over rows of the resulting matrix, matmul against values (\(\cdot V\)). Before we can prove backward passes through these operations, we need the matrix-level extensions of Ch 1’s foundation rules. Each theorem below is the matrix lift of a vector-level theorem from Ch 1, proved by composing the vector version with the row-projection \(\mathrm{ContinuousLinearMap}\). The Lean proofs all live in Proofs/Tensor.lean alongside their vector counterparts. We collect them here because Ch 9 is the first and only chapter to use them.

Definition 48 Matrix VJP record
#

The rank-2 analogue of Definition 9. Define \(\operatorname {pdivMat}f\, A\, (i,j)\, (k,l)\) as \(\operatorname {pdiv}\) of the row-major flattened map \(v \mapsto \mathrm{flatten}\bigl(f(\mathrm{unflatten}\, v)\bigr)\) with both index pairs encoded through the \((i, j) \leftrightarrow \text{flat}\) bijection. Then \(\mathsf{HasVJPMat}\, f\) bundles a backward function \(B\) with: for all \(A\), \(dY\), \((i, j)\),

\[ B(A, dY)_{ij} = \sum _{k, l} \operatorname {pdivMat}f\, A\, (i,j)\, (k,l) \cdot dY_{kl}. \]
Theorem 49 Row-independence for matrices
#

assume:

  1. \(g : \mathbb {R}^{n} \to \mathbb {R}^{p}\) is differentiable everywhere [h_g_diff]

prove: applying \(g\) to each row of a matrix has block-diagonal Jacobian: \(\operatorname {pdivMat}(\text{rowwise } g)\, A\, (i,j)\, (k,l) = [i = k] \cdot \operatorname {pdiv}g\, (A_i)\, j\, l\).

Proof
  1. Coordinate factoring: the \((k, l)\) output coordinate of the flattened rowwise map is \((y \mapsto g(y)_l) \circ \mathrm{rowProj}_k\), where \(\mathrm{rowProj}_k\) is the CLM extracting row \(k\) of the flat vector.
    proof: Unfold flatten/unflatten; \(\mathrm{rowProj}_k\) is a reindex, hence a CLM.

  2. Every output coordinate — hence the whole flat map — is differentiable, so \(\operatorname {fderiv}\) does not fall back to its junk default.
    proof: Assumption 1 composed with the CLM of step 1 (differentiableAt_pi).

  3. q.e.d.
    proof: Chain rule through the CLM: the derivative of \((y \mapsto g(y)_l) \circ \mathrm{rowProj}_k\) at \(\mathbf{e}_{(i,j)}\) sees \(\mathrm{rowProj}_k(\mathbf{e}_{(i,j)}) = [i = k]\, \mathbf{e}_j\), so by Definition 1 the entry is \([i = k] \cdot \operatorname {pdiv}g\, (A_i)\, j\, l\).

Theorem 50 Matrix-level chain rule
#

assume:

  1. \(B_F\), \(B_G\) are correct backward functions (\(\mathsf{HasVJPMat}\, F\), \(\mathsf{HasVJPMat}\, G\)) [hF, hG]

  2. the flattened forms of \(F\) and \(G\) are differentiable everywhere [hF_diff, hG_diff]

prove: \(\mathsf{HasVJPMat}\, (G \circ F)\).

Proof

Sketch: the VJP chain rule (Theorem 10) transcribed to rank-2 indices; every step is the same, with double sums where the rank-1 proof had single sums.

  1. Define \(B(A, dY) := B_F\bigl(A,\; B_G(F(A),\, dY)\bigr)\).

  2. suffices: \(B\) matches the \(\operatorname {pdivMat}\) contraction.
    proof: Definition 48.

  3. Chain rule for \(\operatorname {pdivMat}\): \(\operatorname {pdivMat}(G \circ F)\, A\, (i,j)\, (k,l) = \sum _{p,q} \operatorname {pdivMat}F\, A\, (i,j)\, (p,q) \cdot \operatorname {pdivMat}G\, (F A)\, (p,q)\, (k,l)\).
    proof: The flattened composite is the composite of the flattened maps (unflatten \(\circ \) flatten cancels), so the rank-1 chain rule (Theorem 2) applies at the flat level, applicable by assumption 2; re-index the flat middle sum to \((p, q)\) pairs.

  4. q.e.d.
    proof: Expand the two correct fields in 1, substitute 3, and swap the two double sums (pack indices into pairs, Finset.sum_comm, unpack); with 2 this is the goal — step for step the proof of Theorem 10.

Theorem 51 Matrix-level additive fan-in

Rank-2 transcription of Theorem 11: given \(\mathsf{HasVJPMat}\) for \(F\) and \(G\) (with flat differentiability), \(\mathsf{HasVJPMat}\, (F + G)\) with backward \(B_F(A, dY) + B_G(A, dY)\).

Proof
  1. suffices: the summed backward matches the \(\operatorname {pdivMat}\) contraction.
    proof: Definition 48.

  2. q.e.d.
    proof: Expand both correct fields, merge the double sums (Finset.sum_add_distrib twice); the sum rule for \(\operatorname {pdivMat}\) — Theorem 3 through the flatten bijection — rewrites \(\operatorname {pdivMat}F + \operatorname {pdivMat}G = \operatorname {pdivMat}(F + G)\).

Theorem 52 Matrix-level identity

\(\mathsf{HasVJPMat}\, (\mathrm{id})\), backward \(B(A, dY) = dY\).

Proof
  1. \(\operatorname {pdivMat}(\mathrm{id})\, A\, (i,j)\, (k,l) = [i = k \wedge j = l]\).
    proof: Flatten \(\circ \) unflatten is the identity on the flat vector, so the identity Jacobian (Theorem 5) applies; the flat-index equality decodes to \(i = k \wedge j = l\) by injectivity of the pairing.

  2. q.e.d.
    proof: Contract 1 with \(dY\): the two-dimensional Kronecker sum collapses (Finset.sum_eq_single twice) to \(dY_{ij}\); Definition 48 closes.

Theorem 53 Scalar-scale Jacobian
#

prove: \(\operatorname {pdivMat}(M \mapsto s \cdot M)\, A\, (i,j)\, (k,l) = [i = k \wedge j = l] \cdot s\).

Proof
  1. The flattened scalar-scale map reduces to \(v \mapsto s \cdot v\).
    proof: Flatten/unflatten round-trip, pointwise.

  2. \(\operatorname {pdiv}(v \mapsto s \cdot v) = s \cdot \delta \) at the flat indices.
    proof: Factor as \((\text{const } s) \cdot (\text{identity})\): product rule (Theorem 4), constant rule (Theorem 6), identity Jacobian (Theorem 5).

  3. q.e.d.
    proof: The flat-index equality decodes to \(i = k \wedge j = l\) by injectivity of the pairing.

Theorem 54 Transpose Jacobian
#

prove: \(\operatorname {pdivMat}(\mathrm{transpose})\, A\, (i,j)\, (k,l) = [j = k \wedge i = l]\).

Proof
  1. The flattened transpose is a pure gather: at output index \(\mathrm{idx}\), it reads \(v\) at the index with components swapped.
    proof: Unfold transpose/flatten/unflatten.

  2. q.e.d.
    proof: Reindex Jacobian (Theorem 7) with the swap map \(\sigma \): the indicator condition \(\mathrm{flat}(i,j) = \mathrm{flat}(l,k)\) decodes to \(j = k \wedge i = l\) by injectivity of the pairing.

Theorem 55 Matmul Jacobian, left factor fixed

prove: \(\operatorname {pdivMat}(B' \mapsto C \cdot B')\, B\, (i,j)\, (k,l) = [l = j] \cdot C_{ki}\).

Proof
  1. The flattened map at output index \(\mathrm{idx}\) is \(v \mapsto \sum _s C_{k(\mathrm{idx}), s} \cdot v_{\mathrm{flat}(s,\, l(\mathrm{idx}))}\) — a finite sum of (constant) \(\times \) (reindex) terms.
    proof: Unfold \(\mathrm{Mat.mul}\)/flatten/unflatten.

  2. Per-index Jacobian: finite-sum rule (Theorem 8) distributes over \(s\); product rule with the \(C\)-factor constant (Theorems 4, 6) and reindex Jacobian (Theorem 7) leave \(\sum _s C_{k, s} \cdot [\mathrm{flat}(i,j) = \mathrm{flat}(s, l)]\).

  3. q.e.d.
    proof: The indicator forces \(s = i\) and \(l = j\) (injectivity of the pairing), collapsing the sum to \([l = j] \cdot C_{ki}\).

Theorem 56 Matmul Jacobian, right factor fixed

prove: \(\operatorname {pdivMat}(A' \mapsto A' \cdot D)\, A\, (i,j)\, (k,l) = [i = k] \cdot D_{jl}\).

Proof

Mirror image of Theorem 55: the flattened map at output \(\mathrm{idx}\) is \(v \mapsto \sum _s v_{\mathrm{flat}(k(\mathrm{idx}),\, s)} \cdot D_{s,\, l(\mathrm{idx})}\), the same finite-sum-of-reindex-times-constant shape with the variable factor on the left; the same three steps (finite-sum, product + constant + reindex rules, indicator collapse at \(s = j\), \(k = i\)) give \([i = k] \cdot D_{jl}\).

Theorem 57 Matmul VJP, left factor fixed

\(\mathsf{HasVJPMat}\, (B' \mapsto C \cdot B')\), backward \(dB = C^{T} \cdot dY\).

Proof
  1. Define \(B(B', dY)_{ij} := \sum _k C_{ki}\, dY_{kj}\) (that is, \(C^T \cdot dY\)).

  2. suffices: \(B\) matches the \(\operatorname {pdivMat}\) contraction.
    proof: Definition 48.

  3. q.e.d.
    proof: Substitute the Jacobian (Theorem 55): \(\sum _{k,l} [l = j]\, C_{ki} \cdot dY_{kl}\) collapses at \(l = j\) (Finset.sum_ite_eq’) to \(\sum _k C_{ki}\, dY_{kj} = B\).

Theorem 58 Matmul VJP, right factor fixed

\(\mathsf{HasVJPMat}\, (A' \mapsto A' \cdot D)\), backward \(dA = dY \cdot D^{T}\).

Proof
  1. Define \(B(A', dY)_{ij} := \sum _l dY_{il}\, D_{jl}\) (that is, \(dY \cdot D^T\)).

  2. suffices: \(B\) matches the \(\operatorname {pdivMat}\) contraction.
    proof: Definition 48.

  3. q.e.d.
    proof: Substitute the Jacobian (Theorem 56): \(\sum _{k,l} [i = k]\, D_{jl} \cdot dY_{kl}\) collapses at \(k = i\) (Finset.sum_ite_eq) to \(\sum _l D_{jl}\, dY_{il} = B\).

Theorem 59 Scalar-scale VJP
#

\(\mathsf{HasVJPMat}\, (M \mapsto s \cdot M)\), backward \(dA = s \cdot dY\).

Proof

By Definition 48 it suffices to contract the Jacobian (Theorem 53) with \(dY\): \(\sum _{k,l} [i = k \wedge j = l]\, s \cdot dY_{kl}\) collapses at \((k, l) = (i, j)\) to \(s \cdot dY_{ij}\). q.e.d.

Theorem 60 Transpose VJP
#

\(\mathsf{HasVJPMat}\, (\mathrm{transpose})\), backward \(dA = dY^{T}\).

Proof

By Definition 48 it suffices to contract the Jacobian (Theorem 54) with \(dY\): \(\sum _{k,l} [j = k \wedge i = l] \cdot dY_{kl}\) collapses at \((k, l) = (j, i)\) to \(dY_{ji}\). q.e.d.

Theorem 61 Row-wise lifting of any \(\mathsf{HasVJP}\)

assume:

  1. \(B_g\) is a correct backward for \(g : \mathbb {R}^{n} \to \mathbb {R}^{p}\) (\(\mathsf{HasVJP}\, g\)) [hg]

  2. \(g\) is differentiable everywhere [hg_diff]

prove: \(\mathsf{HasVJPMat}\) of the rowwise map on \(\mathbb {R}^{m \times n} \to \mathbb {R}^{m \times p}\), with backward applying \(B_g\) to each row: \(B(A, dY)_r = B_g(A_r, dY_r)\).

Proof
  1. suffices: \(B\) matches the \(\operatorname {pdivMat}\) contraction.
    proof: Definition 48.

  2. The Jacobian is block-diagonal: \(\operatorname {pdivMat}= [i = k] \cdot \operatorname {pdiv}g\, (A_i)\, j\, l\).
    proof: Row-independence (Theorem 49), applicable by assumption 2.

  3. q.e.d.
    proof: Contract 2 with \(dY\): the row sum collapses at \(k = i\) (Finset.sum_ite_eq); what remains is precisely \(g\)’s own correctness equation at row \(i\) (assumption 1).

Theorem 62 3D chain rule
#

prove: for flattened-differentiable \(f\), \(g\): \(\operatorname {pdiv}_3(g \circ f)\) is the middle-index contraction \(\sum _{c_j, h_j, w_j} \operatorname {pdiv}_3 f \cdot \operatorname {pdiv}_3 g\).

Proof
  1. \(\operatorname {pdiv}_3\) is \(\operatorname {pdiv}\) of the flattened map (Definition 24), and the flattened composite is the composite of the flattened maps.
    proof: Unflatten \(\circ \) flatten cancels between the stages.

  2. q.e.d.
    proof: The rank-1 chain rule (Theorem 2) at the flat level, then re-index the flat middle sum to \((c_j, h_j, w_j)\) triples through the index bijection.

Theorem 63 3D VJP chain rule
#

Rank-3 transcription of Theorem 10: composite backward \(B(x, dy) = B_f\bigl(x,\, B_g(f\, x,\, dy)\bigr)\).

Proof
  1. suffices: the composite backward matches the \(\operatorname {pdiv}_3\) triple-sum contraction.
    proof: Definition 24.

  2. q.e.d.
    proof: Expand both correct fields, substitute the 3D chain rule (Theorem 62), then swap the two triple sums by packing each into a product index (Finset.sum_product, Finset.sum_comm) — the rank-3 rendition of Theorem 10 steps 3–6.

Theorem 64 3D additive fan-in

Rank-3 transcription of Theorem 11: backward is the sum of the two backwards.

Proof

By Definition 24 it suffices to match the triple-sum contraction: expand both correct fields, merge the three nested sums (Finset.sum_add_distrib three times), and apply the sum rule (Theorem 3, through the flatten bijection) to rewrite \(\operatorname {pdiv}_3 f + \operatorname {pdiv}_3 g = \operatorname {pdiv}_3(f + g)\). q.e.d.

9.3 Attention proofs

Theorem 65 Softmax Jacobian
#

Writing \(p := \mathrm{softmax}(z)\): prove: \(\operatorname {pdiv}(\mathrm{softmax})\, z\, i\, j = p_j\, (\delta _{ij} - p_i)\).

Proof
  1. \(\operatorname {pdiv}(\mathrm{softmax})\, z\, i\, j = \operatorname {fderiv}_{\mathbb {R}}\, \bigl(z' \mapsto e^{z'_j} \cdot (\textstyle \sum _k e^{z'_k})^{-1}\bigr)\, z\, (\mathbf{e}_i)\).
    proof: Definition 1; extract the \(j\)-th coordinate (fderiv_apply), differentiable because the denominator \(S := \sum _k e^{z_k}\) is a sum of positive exponentials, hence nonzero.

  2. Differentiate the product: numerator via HasFDerivAt.exp, denominator via (hasDerivAt_inv).comp_hasFDerivAt (licensed by \(S {\gt} 0\)).

  3. Evaluate at \(\mathbf{e}_i\): the numerator’s derivative contributes \(e^{z_j}\, \delta _{ji}\), and the denominator’s contributes \(-e^{z_j}\, S^{-2} \cdot e^{z_i}\), the inner sum collapsing by \(\sum _k e^{z_k}\, \delta _{ki} = e^{z_i}\).

  4. q.e.d.
    proof: Combine 2–3 and rewrite in terms of \(p = e^{z}/S\): \(S^{-1} e^{z_j} \delta _{ij} - e^{z_j} S^{-2} e^{z_i} = p_j(\delta _{ij} - p_i)\) (field_simp, ring).

Theorem 66 Standalone softmax VJP
#

\(\mathsf{HasVJP}\, (\mathrm{softmax})\), with the closed-form \(O(c)\) backward

\[ B(z, dy)_i = p_i\, \bigl(dy_i - \langle p, dy \rangle \bigr), \]

where \(\langle p, dy \rangle = \sum _j p_j\, dy_j\) is one precomputed scalar — the rank-1 structure of the Jacobian is what turns the naive \(O(c^2)\) contraction into a reduction plus a broadcast, the same optimization pattern as BN and max-pool.

Proof
  1. suffices: \(B(z, dy)_i = \sum _j \operatorname {pdiv}(\mathrm{softmax})\, z\, i\, j \cdot dy_j\).
    proof: Definition 9.

  2. \(\sum _j p_j (\delta _{ij} - p_i)\, dy_j = p_i\, dy_i - p_i \sum _j p_j\, dy_j\).
    proof: Substitute the softmax Jacobian (Theorem 65); split the sum, collapse the \(\delta \)-term (Finset.sum_ite), factor \(p_i\) out of the second (Finset.mul_sum).

  3. q.e.d.
    proof: The right side of 2 is \(p_i(dy_i - \langle p, dy\rangle ) = B(z, dy)_i\); with 1, done.

Theorem 67 Row-wise softmax VJP on a matrix

\(\mathsf{HasVJPMat}\, (\mathrm{rowSoftmax})\): rows are independent, so the backward applies the softmax backward per row.

Proof

The rowwise-lifting argument (Theorem 61) instantiated at \(g = \mathrm{softmax}\):

  1. The Jacobian is block-diagonal with the standalone softmax Jacobian in each block.
    proof: Row-independence (Theorem 49); softmax is differentiable (positive denominator).

  2. q.e.d.
    proof: Contract with \(dY\), collapse the row sum at \(k = i\); what remains is Theorem 66’s correctness equation at row \(i\); Definition 48 closes.

Theorem 68 SDPA backward wrt Q

prove: for fixed \(K, V\): \(\mathrm{sdpa\_ back\_ Q}\) matches the \(\operatorname {pdivMat}\) contraction of \(Q' \mapsto \mathrm{sdpa}(Q', K, V)\).

Proof
  1. The forward is a four-link chain:

    \[ Q' \; \mapsto \; Q' K^T \; \mapsto \; \tfrac {1}{\sqrt{d}} \cdot {\mathord {@}} \; \mapsto \; \mathrm{rowSoftmax}\, {\mathord {@}} \; \mapsto \; {\mathord {@}} \cdot V. \]


    proof: Definitional (sdpa_Q_chain_eq).

  2. Each link has a proved \(\mathsf{HasVJPMat}\): matmul-right-const at \(K^T\) (Theorem 58), scalar-scale (Theorem 59), row-wise softmax (Theorem 67), matmul-right-const at \(V\); glue with the matrix chain rule (Theorem 50) three times.
    proof: The flat-differentiability side conditions: the first two links are linear (fun_prop) and rowSoftmax is smooth (Theorem 78); compositions inherit.

  3. q.e.d.
    proof: The chain’s composed backward literally computes \(\mathrm{sdpa\_ back\_ Q}\)’s nested formula (pure unfolding), so the chain’s correct field is the goal.

Theorem 69 SDPA backward wrt K

prove: for fixed \(Q, V\): \(\mathrm{sdpa\_ back\_ K}\) matches the \(\operatorname {pdivMat}\) contraction of \(K' \mapsto \mathrm{sdpa}(Q, K', V)\).

Proof

Same shape as Theorem 68, with a leading transpose link:

  1. The forward chain is \(K' \mapsto K'^T \mapsto Q \cdot {\mathord {@}} \mapsto \tfrac {1}{\sqrt{d}} \cdot {\mathord {@}} \mapsto \mathrm{rowSoftmax}\, {\mathord {@}} \mapsto {\mathord {@}} \cdot V\), the second link now matmul-left-const (Theorems 60, 57).

  2. Glue the five links with Theorem 50 (four applications), differentiabilities as before.

  3. q.e.d.
    proof: The chain’s backward computes \(\sum _k Q_{kj}\, d\mathrm{Scores}_{ki}\) while \(\mathrm{sdpa\_ back\_ K} = (d\mathrm{Scores})^T Q\) expands to \(\sum _k d\mathrm{Scores}_{ki}\, Q_{kj}\) — equal by mul_comm at the summand.

Theorem 70 SDPA backward wrt V
#

prove: for fixed \(Q, K\): \(\mathrm{sdpa\_ back\_ V}\) matches the \(\operatorname {pdivMat}\) contraction of \(V' \mapsto \mathrm{sdpa}(Q, K, V')\).

Proof
  1. \(V'\) enters only through the final matmul: \(\mathrm{sdpa}(Q, K, V') = W \cdot V'\) with \(W := \mathrm{sdpa\_ weights}(Q, K)\) fixed.
    proof: Definitional (sdpa_eq_mul_weights).

  2. q.e.d.
    proof: Theorem 57’s backward is \(W^T \cdot d\mathrm{Out}\), which unfolds to exactly \(\mathrm{sdpa\_ back\_ V}\).

Theorem 71 Multi-head SDPA VJP

prove: \(\mathsf{HasVJPMat}\, (\mathrm{mhsa\_ layer})\).

Proof

Sketch: column-stacking — each head touches only its own column slab, so the single-head SDPA backward lifts across the head axis the same way row-independence lifts vector VJPs across rows.

  1. \(\mathrm{mhsa\_ layer}\) factors as \((\text{per-token dense } W_o) \circ \mathrm{colSlabApply}(\text{single-head SDPA}) \circ (\text{per-token fused QKV dense})\).
    proof: Definitional (mhsa_layer_eq_compose).

  2. The middle factor’s Jacobian is block-diagonal across heads: pdivMat_colIndep, the column-axis analogue of row-independence (Theorem 49), lifts the proved single-head backward (Theorems 6870) over the head axis (colSlabwise_has_vjp_mat).

  3. Glue the three factors with the matrix chain rule (Theorem 50) twice; the dense factors are per-token lifts (Theorem 76).

  4. q.e.d.
    proof: The backward field is taken from the composed witness directly (kernel-cheap projection); the composition-equality transport of step 1 lives only in the correct field — a Prop the kernel never reduces.

Theorem 72 MHSA layer smoothness
#

\(\mathsf{Differentiable}\) sibling of Theorem 71.

Proof

Factor as in Theorem 71 step 1; each factor’s flattened form is differentiable (two per-token dense lifts, and \(\mathrm{colSlabApply}\) of the single-head function, whose smoothness comes from the same chain of exp/inv positivity as Theorem 78); compositions inherit differentiability. q.e.d.

Theorem 73 Patch-embedding VJP

The concrete def unfolds to conv-with-stride + CLS prepend + positional embed. prove: \(\mathsf{HasVJP}\, (\mathrm{patchEmbed\_ flat})\), with backward the deconvolution formula: a sum over patches with reconstructed kernel offsets, the CLS row contributing nothing to the image gradient.

Proof

Sketch: the conv2d input-VJP recipe (Theorem 25) reapplied to the strided patch convolution, with one new wrinkle at the collapse: the CLS row.

  1. Define \(B(\mathrm{img}, dy)\) at input position \((c, h, w)\) as \(\sum _{p, k_h, k_w} [\text{offset match}] \sum _d W^{\mathrm{conv}}_{d, c, k_h, k_w} \cdot dy_{\mathrm{flat}(p + 1,\, d)}\), where patch \(p\) decodes to grid position \((\lfloor p / W' \rfloor , p \bmod W')\).

  2. suffices: \(B\) matches the \(\operatorname {pdiv}\) contraction.
    proof: Definition 9.

  3. The forward decomposes as \((\text{constant: bias} + \text{CLS} + \text{pos-embed}) + (\text{img-linear part})\), where the pad guard absorbs the \(n = 0\) (CLS-row) test so the linear part is uniform in \(\mathrm{img}\).
    proof: Unfold; definitional.

  4. Per-index Jacobian by the conv2d recipe: sum + constant rules drop the constants (Theorems 3, 6); finite-sum rule (Theorem 8) \(\times 3\); product rule (Theorem 4) with constant \(W^{\mathrm{conv}}\) factor; guarded projection = reindex-or-zero (Theorems 7, 6).

  5. Contract with \(dy\) and collapse as in Theorem 25 step 5, with the new wrinkle: split the output-row sum over \(\mathrm{Fin}(N{+}1)\) into the CLS row (contributes \(0\)) plus \(\sum _{p : \mathrm{Fin}\, N}\) at rows \(n = p + 1\) (Fin.sum_univ_succ).

  6. q.e.d.
    proof: What remains is \(B\); with 2, done.

Theorem 74 Patch-embedding smoothness
#

\(\mathsf{Differentiable}\) sibling of Theorem 73.

Proof

The decomposition of Theorem 73 step 3 exhibits the flattened forward as constant + linear-in-img (each summand a constant times a guarded projection); both pieces are differentiable and the sum inherits it. q.e.d.

Theorem 75 Per-token LayerNorm lifted to a matrix
Proof

Instantiate the rowwise lift (Theorem 61) at \(g = \mathrm{layerNormForward}\), with Theorem 46 as the \(\mathsf{HasVJP}\) witness and its differentiability sibling (\(\varepsilon {\gt} 0\)) as the smoothness witness. The backward is block-diagonal: each token’s gradient is computed independently by the 1D three-term formula. q.e.d.

Theorem 76 Per-token dense lifted to a matrix
Proof

Instantiate the rowwise lift (Theorem 61) at \(g = \mathrm{dense}(W, b)\), with Theorem 18 as the \(\mathsf{HasVJP}\) witness; dense is affine, hence everywhere differentiable. This is \(Q = XW + b\) row-by-row with shared weights. q.e.d.

Theorem 77 Per-token GELU lifted to a matrix
Proof

Instantiate the rowwise lift (Theorem 61) at \(g = \mathrm{gelu}\), with Theorem 47 as the \(\mathsf{HasVJP}\) witness and the tanh-chain smoothness as the differentiability witness. Elementwise activation: the Jacobian is diagonal both across rows and within a row. q.e.d.

Theorem 78 Row-wise softmax smoothness
#

prove: the flattened \(\mathrm{rowSoftmax}\) is \(\mathsf{Differentiable}\).

Proof
  1. case \(n = 0\): the codomain is \(0\)-dimensional; the map is constant.
    proof: Trivial.

  2. case \(n \ge 1\): each output coordinate is \(v \mapsto e^{v_{(r, c)}} \cdot \bigl(\sum _j e^{v_{(r, j)}}\bigr)^{-1}\). The denominator is a positive finite sum (Real.exp_pos, Finset.sum_pos over the nonempty index set), so Differentiable.inv applies and the product of exponential chains closes.

  3. q.e.d.
    proof: Differentiability per coordinate gives differentiability of the Pi-valued map (differentiable_pi).

Theorem 79 Transformer MLP sublayer VJP

\(\mathsf{HasVJPMat}\) of \(\mathrm{dense}_2 \circ \mathrm{gelu} \circ \mathrm{dense}_1\), all per-token.

Proof
  1. Inner composition \(\mathrm{gelu} \circ \mathrm{dense}_1\): matrix chain rule (Theorem 50) on the two per-token lifts (Theorems 76, 77); both flat forms are differentiable (dense affine, GELU via the tanh chain), and the composition inherits it.

  2. q.e.d.
    proof: One more chain-rule application against the outer \(\mathrm{dense}_2\) lift.

Theorem 80 Transformer attention sublayer with residual VJP

assume: \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]
prove: \(\mathsf{HasVJPMat}\) of \(X \mapsto X + \mathrm{MHSA}(\mathrm{LN}_1(X))\).

Proof
  1. Inner arm \(\mathrm{MHSA} \circ \mathrm{LN}_1\): matrix chain rule (Theorem 50) on Theorems 75 and 71, differentiabilities from the \(\varepsilon {\gt} 0\) LN smoothness and Theorem 72.

  2. q.e.d.
    proof: Matrix additive fan-in (Theorem 51) of the identity arm (Theorem 52) and the inner arm — the same residual pattern as Theorem 37, one rank up.

Theorem 81 Transformer MLP sublayer with residual VJP

assume: \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]
prove: \(\mathsf{HasVJPMat}\) of \(h \mapsto h + \mathrm{MLP}(\mathrm{LN}_2(h))\).

Proof

Identical structure to Theorem 80: chain \(\mathrm{MLP} \circ \mathrm{LN}_2\) (Theorems 79, 75 via Theorem 50), then \(\operatorname {biPathMat}\) with the identity arm (Theorems 51, 52). q.e.d.

Theorem 82 Transformer block VJP

prove: \(\mathsf{HasVJPMat}\) of one pre-norm encoder block \(z = x + \mathrm{MHSA}(\mathrm{LN}_1 x)\), \(y = z + \mathrm{MLP}(\mathrm{LN}_2 z)\).

Proof

One matrix chain rule (Theorem 50) glueing the attention sublayer (Theorem 80) to the MLP sublayer (Theorem 81), with each sublayer’s flat differentiability discharged from its arms (both are \(\mathrm{id} + \text{smooth}\)). q.e.d.

Theorem 83 Transformer tower, any depth

prove: \(\mathsf{HasVJPMat}\) of the \(k\)-block tower, for every \(k\) — ViT-Tiny/Base (\(k = 12\)) and Large (\(k = 24\)) are instances.

Proof

Induction on \(k\):

  1. case \(k = 0\): the tower is the identity.
    proof: Theorem 52.

  2. case \(k + 1\): the tower is \(\mathrm{block} \circ \mathrm{tower}_k\).
    proof: Matrix chain rule (Theorem 50) on the induction hypothesis and one block (Theorem 82), with the tower’s flat differentiability carried through the same induction.

  3. q.e.d.
    proof: By induction, 1 and 2 cover every depth.

Theorem 84 ViT body: the grand finale

prove: the full ViT transformer backbone \(\mathrm{finalLN} \circ \mathrm{transformerTower}\) is one \(\mathsf{HasVJPMat}\).

Proof

One final matrix chain rule (Theorem 50) glueing the \(k\)-block tower (Theorem 83) to the final per-token LayerNorm (Theorem 75). Every step of the chain that began with vjp_comp in Chapter 1 is a proved theorem; this is its last link. q.e.d.

9.4 Example: ViT-Tiny on Imagenette

This is the capstone. Every layer proved in the preceding chapters composes into this one architecture, and that list runs dense, ReLU, softmax cross-entropy, convolution, maxPool, batch norm, residual, depthwise conv, squeeze-and-excitation, layer norm, GELU, and the attention mechanism itself. If the VerifiedNetSpec below compiles and the VJP of its body is proved (§ 84), then every layer in modern deep learning you’ve seen in this book has its backward pass machine-checked.

ViT-Tiny (Dosovitskiy et al. 2020) was the smallest Vision Transformer variant in the original paper. It’s also the least data-hungry, and the big ViT variants start beating ConvNets only at ImageNet-21K scale and above. On a 9469-image Imagenette training set, ViT-Tiny underperforms every ConvNet in Part 1. That’s part of the teaching point: transformers are not a universal improvement, they are a different scaling regime.

The architecture

ViT-Tiny is the shortest spec in Part 1: .patchEmbed chops the image into \(16 \times 16\) patches and projects each to a 192-dim token (196 of them, plus a prepended CLS token), .transformerEncoder runs twelve pre-norm blocks, and a final LayerNorm feeds the CLS token to a dense head. The inset shows one of the twelve transformer blocks, which is two residual sub-blocks, attention then MLP, each pre-normed.

\begin{tikzpicture} [
  >={Stealth[length=1.8mm]},
  every node/.style={font=\sffamily\scriptsize},
  col/.style    = {align=center, rounded corners=2pt, inner sep=2pt, minimum height=0.56cm, minimum width=6.7cm},
  io/.style     = {col, draw=blue!55!black,   fill=blue!8},
  norm/.style   = {col, draw=teal!60!black,   fill=teal!10},
  blk/.style    = {col, draw=blue!45!violet,  fill=violet!8},
  head/.style   = {col, draw=red!60!black,    fill=red!8},
  logits/.style = {col, draw=green!50!black,  fill=green!14, very thick},
  arr/.style    = {->, thick, gray!60, shorten >=1pt, shorten <=1pt},
  stage/.style  = {font=\sffamily\scriptsize\itshape, gray!55!black, anchor=west},
]
  \node[io]                          (input){Input \;\; $224\times224\times3$};
  \node[norm, below=0.16cm of input] (pe)   {\textbf{PatchEmbed} $16\times16$ patches $\to 196\times192$};
  \node[norm, below=0.16cm of pe]    (cls)  {prepend CLS token $+$ pos embed \;\; $197\times192$};
  \node[blk, below=0.16cm of cls]    (enc)  {$12\times$ \textbf{Transformer encoder}\\dim 192, 3 heads, MLP 768};
  \node[norm, below=0.16cm of enc]   (ln)   {final LayerNorm};
  \node[head, below=0.16cm of ln]    (d)    {\textbf{Dense} $192\to10$ \;(identity), on CLS};
  \node[logits, below=0.16cm of d]   (out)  {Logits \;\; 10 classes, softmax-CE};
  \foreach \a/\b in {input/pe,pe/cls,cls/enc,enc/ln,ln/d,d/out}\draw[arr](\a)--(\b);
  \node[stage] at ($(enc.east)+(0.30,0)$){$\times 12$ blocks};
\end{tikzpicture}
\begin{tikzpicture} [
  >={Stealth[length=1.6mm]},
  every node/.style={font=\sffamily\scriptsize},
  proc/.style={align=center, rounded corners=2pt, inner sep=3pt, minimum height=0.58cm, draw=blue!45!violet, fill=violet!8},
  op/.style  ={circle, draw=blue!45!violet, fill=violet!14, inner sep=0pt, minimum size=0.46cm},
  term/.style={font=\sffamily\scriptsize\itshape, inner sep=1.5pt},
  flow/.style={->, thick, gray!60, shorten >=1.5pt, shorten <=1.5pt},
  skip/.style={->, thick, blue!45!violet, shorten >=1.5pt},
]
  \node[term] (x){$x$};
  \node[proc, right=0.5cm of x]   (ln1){LN};
  \node[proc, right=0.35cm of ln1](mh){MHSA};
  \node[op,   right=0.5cm of mh]  (s1){$+$};
  \node[proc, right=0.5cm of s1]  (ln2){LN};
  \node[proc, right=0.35cm of ln2](ml){MLP};
  \node[op,   right=0.5cm of ml]  (s2){$+$};
  \node[term, right=0.5cm of s2]  (y){$y$};
  \foreach \a/\b in {x/ln1,ln1/mh,mh/s1,s1/ln2,ln2/ml,ml/s2,s2/y}\draw[flow](\a)--(\b);
  \draw[skip] (x.north) .. controls +(0,0.72) and +(0,0.72) .. (s1.north)
        node[midway, above, term, blue!45!violet]{$+\,x$};
  \draw[skip] (s1.north) .. controls +(0,0.72) and +(0,0.72) .. (s2.north)
        node[midway, above, term, blue!45!violet]{$+\,z$};
  \node[term, below=0.18cm of ln2, gray!55!black]{one transformer block: $z = x + \mathrm{MHSA}(\mathrm{LN}\,x)$, \ $y = z + \mathrm{MLP}(\mathrm{LN}\,z)$};
\end{tikzpicture}

The verified trainer

§5.3 is the pattern, and every chapter since Chapter 6 has reused it. ViT drops the same one of its four annotated points that ConvNeXt does. There is no bnChannels here, because there is no BatchNorm.

import LeanMlir

-- 1. The network. `slug` names the committed render. No `bnChannels`:
--    LayerNorm keeps no running statistics, so nothing has to be
--    threaded from training into evaluation.
def vitVerified : VerifiedNetSpec where
  name     := "ViT-Tiny"
  slug     := "vit"
  inC      := 3
  imageH   := 224
  imageW   := 224
  nClasses := 10
  data     := .imagenette
  layers   := [
    .conv 3 192 16 16,          -- patch embed 16x16/s16, 224 -> 14x14 = 196
    .param #[192] 2,            -- CLS token
    .param #[197, 192] 2,       -- positional embedding, 196 + 1
    .transformerBlock 192 768,  -- 12 pre-norm blocks @ dim 192, MLP 768
    .transformerBlock 192 768,  .transformerBlock 192 768,
    .transformerBlock 192 768,  .transformerBlock 192 768,
    .transformerBlock 192 768,  .transformerBlock 192 768,
    .transformerBlock 192 768,  .transformerBlock 192 768,
    .transformerBlock 192 768,  .transformerBlock 192 768,
    .layerNorm 192,             -- final LayerNorm, per-channel
    .dense 192 10 ]             -- CLS-head 192 -> 10
  -- Stochastic depth, read only by the `*drop` variants. TWENTY-FOUR
  -- entries for TWELVE blocks: ViT drops a block's two residual
  -- branches independently, at one keep shared by the pair.
  dropKeeps := (Array.range 24).map (fun s =>
                 1.0 - 0.1 * (s / 2).toFloat / 11.0)

-- 2. The schedule. Note it is NOT the other four nets' schedule.
def vitAdamConfig : VerifiedConfig where
  epochs    := 80
  batchSize := 32

-- 3. The entry point: AdamW, cosine with a 5-epoch warmup,
--    baseLR 3e-4.
def main (argv : List String) : IO Unit :=
  vitVerified.toNet.trainAdamSched
    vitAdamConfig (argv.head?.getD "data")
    0.0003 0.9 0.999 5 "adam"

The body is a patch embedding, twelve blocks and a head. The patch embed is spelled .conv 3 192 16 16, a \(16 \times 16\) kernel at stride 16, which chops the \(224 \times 224 \times 3\) image into \(14 \times 14 = 196\) non-overlapping patches and projects each to 192 dimensions. That it is a convolution rather than a distinct primitive is the point: a patch embedding is a strided convolution whose kernel equals its stride, so it reuses Chapter 3’s proved backward and needs nothing new.

The CLS token and the positional embedding are .param entries. They are learned weights that no layer computes from an input, and the spec’s job is the parameter layout, so it names them rather than folding them into a block. The \(197\) is \(196\) patches plus the prepended CLS row.

.transformerBlock 192 768 is one block, not a stage. There is no repeat count, so twelve blocks are twelve entries, exactly as ConvNeXt’s eighteen were eighteen. Each carries the pre-norm sandwich drawn in the inset above: LayerNorm, MHSA, residual, LayerNorm, MLP, residual.

The arm, and why sixteen tensors is the whole block.

  | param dims kind    => #[(dims, kind)]              -- Ch. 9: CLS, pos-embed
  | transformerBlock d m =>                            -- Ch. 9
    #[(#[d],1),(#[d],2),                               -- LN1
      (#[d,d],0),(#[d],2), (#[d,d],0),(#[d],2),        -- Wq, Wk
      (#[d,d],0),(#[d],2), (#[d,d],0),(#[d],2),        -- Wv, Wo
      (#[d],1),(#[d],2),                               -- LN2
      (#[d,m],0),(#[m],2), (#[m,d],0),(#[d],2)]        -- MLP d->m->d

.param is the escape hatch: a bare learned tensor with no layer around it, which is how the CLS token and the positional embedding get into the list at all. Everything else here is a dense layer wearing a different name — the four attention projections are [d,d] matrices with biases, indistinguishable in the parameter list from Chapter 1’s .dense. What makes them attention is the graph, not the shapes. Sixteen tensors a block, twelve blocks, plus the patch embed, the two .params, the final LayerNorm and the head: 200.

200 parameter tensors, 5,526,346 scalars. vitVerified.toSpecs is kernel-#guarded against ViTLayout.specs, an independently audited hand-list, and the ImageNet spec of §9.6 is pinned to this one at everything but its head. At 1000 classes the count is 5,717,416. So the listing above is not a description of the network that got trained, it is the object the renderer consumed.

Twenty-four keeps for twelve blocks, and the pairing is the content. ViT drops each block’s attention branch and MLP branch independently, but at the same keep probability, so sites \(2i\) and \(2i+1\) share \(\mathrm{keep}_i = 1 - 0.1i/11\). The driver needs one entry per site because it draws one Bernoulli stream per mask input. Deriving twenty-four evenly spaced keeps from the site ordinal instead would silently unpair them, and no structural check would notice.

Note the learning rate is 0.0003 (a third of what the ConvNets used) and warmup is 5 epochs (longer than the ConvNets’ 3). ViTs need gentler optimization schedules, because the attention softmax is prone to collapse if gradients push logits too hard early, and warmup keeps the first few epochs slow enough to avoid that failure mode.

Results

§9.1 has the run: 80 epochs at about 18 seconds each, twenty-four minutes, \(\mathbf{68.74\% }\) top-1 and \(\mathbf{90.42\% }\) top-5. That completes the Part-1 column, and every row of it is now a measurement on the verified XLA path at the same dataset, the same eighty epochs, and one RTX 4060 Ti:

Model

Params

MLIR

Step time

Total

Top-1

Top-5

ResNet-34

21.29M

729 KB

220 ms

1.5 h

89.71%

98.27%

MobileNet V2

2.24M

1,047 KB

90 ms

35 min

89.25%

98.68%

EfficientNet-B0

4.02M

1,316 KB

103 ms

41 min

89.96%

98.45%

ConvNeXt-T

27.83M

985 KB

196 ms

1.3 h

85.07%

97.30%

ViT-Tiny

5.53M

1,282 KB

61 ms

24 min

68.74%

90.42%

Step time is the epoch wall-clock divided by 295 batches, so it includes each epoch’s validation pass. MLIR sizes are the committed <slug>_adam_train_step.mlir in 1000-byte units.

ViT-Tiny is the fastest per step by a factor of \(1.5\) over the next network and the lowest-accuracy by more than sixteen points. That gap is the data-hunger effect: 9,469 training images is tiny for a transformer. The ViT paper reached ImageNet-competitive accuracy only when trained on ImageNet-21K (14M images) or JFT-300M (300M images). At Imagenette scale the ConvNets’ inductive bias, locality and translation equivariance, actively helps, and the ViT has to learn those properties from data it doesn’t have. §9.6 is the control: the same architecture on 1.28M images reaches \(72.31\% \) over a thousand classes, which is the regime the design was for.

It reproduces exactly. Chapter 3’s width sweep is where this book first had to take run-to-run spread seriously, so this number was measured three times before being printed, and all three passes came back bit-identical on three different cards: the same \(68.738854\% \) and \(90.420382\% \) at epoch 80, and the same 160 epoch lines the whole way down. That makes ViT the first network since the dense-only graphs of Chapter 2 to reproduce exactly, and for the same reason. XLA selects convolution algorithms per process, which is what moves the convnet chapters’ numbers by a point or two between runs. This graph holds 435 dot_generals and two convolutions, so there is almost nothing for that selection to vary.

9.5 MLIR: Attention

What is already proven. Scaled dot-product attention is \(\mathrm{sdpa}(Q,K,V) = \mathrm{softmax}(QK^{\! \top }/\sqrt{d})\, V\). Its reverse-mode derivative produces three gradients (\(dQ\), \(dK\), \(dV\)), each proven separately (§§ 6870, sdpa_back_Q/K/V), all built on the softmax backward, which couples every position to every other (§ 66). Because \(Q\), \(K\), and \(V\) are three linear projections of the same input \(a\), the input gradient is a three-way fan-in, \(da = dQ\, W_q^{\! \top } + dK\, W_k^{\! \top } + dV\, W_v^{\! \top }\). sdpa_has_vjp_mat3 bundles the three, and transformerAttnSublayer_has_vjp_mat composes the whole sublayer (LayerNorm, the QKV projections, attention, the output projection, the residual) through vjp_comp_at.

The gap and how we close it. The softmax backward is non-elementwise and the three projections are coupled. The emitted graph denotes transformerAttnSublayer_has_vjp_mat’s backward. Here is the three-way fan-in at the input (two tokens, model dimension four, single head, with the SDPA backward that produces \(dQ,dK,dV\) elided to its comment):

// dQ,dK,dV = SDPA.back(dOut . Wo^T)  (softmax-weighted attention grads)
%daQ = stablehlo.dot_general %dQ, %Wq, contracting_dims = [1] x [1]
         : (tensor<2x4xf32>, tensor<4x4xf32>) -> tensor<2x4xf32>
%daK = stablehlo.dot_general %dK, %Wk, contracting_dims = [1] x [1]
         : (tensor<2x4xf32>, tensor<4x4xf32>) -> tensor<2x4xf32>
%daV = stablehlo.dot_general %dV, %Wv, contracting_dims = [1] x [1]
         : (tensor<2x4xf32>, tensor<4x4xf32>) -> tensor<2x4xf32>
%daQK = stablehlo.add %daQ, %daK : tensor<2x4xf32>
%da = stablehlo.add %daQK, %daV : tensor<2x4xf32>
// %dxa = LN.back(%da);  %dx = %dOut + %dxa  (residual skip)

Read it against the fan-in: each dot_general carries one of the attention gradients back through its projection (\(dQ\) through \(W_q\), and so on, contracting the model axis), and the two adds sum the three. This is the residual fan-in of Chapter 5 with three branches instead of one, because attention reads the input thrice, so the gradient returns thrice and adds. The softmax-weighted \(dQ,dK,dV\) that feed the dot_generals come from the proven sdpa_back_Q/K/V, and the surrounding LayerNorm and output projection chain through their bridges.

One honesty note: the hard part of attention is in that first comment. This listing is the projection fan-in (three linear adjoints and two adds), while the softmax backward that couples every token to every other (softmax_has_vjp, § 9.3) is what // SDPA.back stands in for. We show the fan-in because it is the new structural move. The coupled core is carried by the proofs of § 9.3.

With attention pinned, the verified-codegen thread covers every operator in the modern vision stack, and that means dense, convolution, BatchNorm, residual, depthwise, squeeze-excite, layer scale and attention. Each is emitted as the rendering of a machine-checked derivative, from the same three pieces: denoted IR, bridge theorem, printer.

Caveats.

  • The attention bridge is unconditional. Softmax and the linear projections are smooth everywhere, with no smooth-point clause.

  • Representative scale (two tokens, \(d = 4\), one head).

9.6 ImageNet recipe

Why the phase-2 trainer? The ImageNet numbers in this book come from the phase-2 (Lean\(\to \)JAX) trainer: at this scale its job is to validate the framework’s logic end to end, and it is the reference the verified path is measured against. Phase-3 verified-IREE codegen led to phase 4 — PJRT, which is the verified path itself. Where a section has both, it reports both.

The Imagenette ViT-Tiny above trained on \(\sim \)9.5K images. The full 1000-class run is the same architecture with a 1000-class head, the .imagenet dataset, and the DeiT-flavored training recipe, so it is still patch16, embed 192, 12 transformer blocks and 3 heads, at 5,717,416 parameters. This is the phase-2 reference implementation, jax/MainVitImagenet.lean, quoted as it stands today:

-- 1. Same ViT-Tiny backbone, 1000-class head.
def vitTinyImagenet : NetSpec where
  name   := "ViT-Tiny (ImageNet, bf16)"
  imageH := 224
  imageW := 224
  layers := [
    .patchEmbed 3 192 16 196,            -- (224/16)^2 = 196 patches
    .transformerEncoder 192 3 768 12,    -- 12 blocks, 3 heads, MLP 768
    .dense 192 1000 .identity            -- 1000-class head
  ]

-- 2. Full DeiT-Ti recipe: AdamW, cosine, the aug suite, stochastic depth, EMA.
def vitTinyImagenetConfig : TrainConfig where
  learningRate      := 5e-4          -- proper DeiT batch-512 LR
  batchSize         := 512
  epochs            := 300           -- full DeiT-Ti schedule
  useAdam           := true          -- AdamW (decoupled weight decay)
  weightDecay       := 0.05
  wdExcludeNormBias := true          -- no_weight_decay: skip norm/bias/pos-embed/CLS
  cosineDecay       := true
  warmupEpochs      := 5
  labelSmoothing    := 0.1
  gradClipNorm      := 1.0           -- the unlock for the 5e-4 LR (see below)
  useMixup          := true          -- Mixup alpha 0.8
  useCutmix         := true          -- CutMix alpha 1.0 (alternates with Mixup)
  useRandAugment    := true          -- rand-m9-mstd0.5-inc1 (color + geometric)
  randomErasing     := true          -- Random-Erase p 0.25
  dropPath          := 0.1           -- stochastic depth (linear 0 -> 0.1)
  useEMA            := true          -- model EMA decay 0.99996 (eval on shadow)
  bf16              := true          -- bf16 matmul compute
  valEveryEpochs    := 5             -- ImageNet val is data-loading-bound
  repeatedAug       := 3             -- DeiT Repeated Augmentation (see below)

-- 3. Weight init. Xavier-uniform is the emitter's generic path, and at
--    ViT-Ti's d=192 it is 3.6x wider than timm's fixed 0.02. vitInit
--    selects trunc_normal(0.02) for every transformer Linear and leaves
--    the patch-embed conv on PyTorch's Conv2d default. The run reported
--    below used THIS recipe, not the bare config above.
def vitTinyImagenetConfigDeitInit : TrainConfig :=
  { vitTinyImagenetConfig with vitInit := true }

-- 4. Two named recipes, chosen by a positional CLI arg. This is the
--    phase-2 reference driver; the verified path's entry point is
--    trainAdamSched, in the phase-4 subsection at the end of this chapter.
def vitTinyImagenetRecipes : List Recipe := [
  { name := "default",   cfg := vitTinyImagenetConfig,         ... },
  { name := "deit-init", cfg := vitTinyImagenetConfigDeitInit, ... }
]

def main (args : List String) : IO Unit :=
  runRecipeMain "vit-tiny-imagenet" vitTinyImagenet .imagenet
    vitTinyImagenetRecipes args

The two recipes differ in exactly one field, and the trainers they generate differ in exactly one function: init_params, 59 initialization sites, with the schedule, augmentation, EMA and evaluation identical byte-for-byte.

That driver wires .imagenet through tfds streaming. §4 is the verified path’s side of it.

Gradient clipping is load-bearing. At the DeiT learning rate (peak \(5\times 10^{-4}\) for batch 512) the model collapses to chance the moment warmup ramps past \({\sim }1.6\times 10^{-4}\), train loss pinned at \(\ln (1000)\approx 6.9\) and never recovering. gradClipNorm := 1.0 is the whole fix; the same LR then trains through warmup to convergence.

bf16. ViT is matmul-bound — patch embed, QKV, scores, output, MLP and head are all dense matmuls, no convolution — so bf16 is the only precision flag that applies and there is no bf16Conv to set. It buys \(\mathbf{1.47\times }\): \(205.1 \to 139.2\) ms/step, \(624 \to 919\) img/s, measured on one RTX 3060 at batch 128 (jax/scripts/jax_vit_bench.py tiny 128, synthetic). The multiple is below the convnets’ because ViT-Ti’s matmuls are narrow (\(d{=}192\)) and proportionally more time goes to the fp32 LayerNorm/softmax that bf16 leaves alone. The run:

GPU

Precision

Per epoch

Epochs

Wall-clock

Val top-1

Val top-5

4\(\times \) 4060 Ti (CUDA)

bf16

\(\sim \)7.6 min

300

\(\sim \)41 hr

4\(\times \) 3060 (CUDA)

bf16

6.8 min

300

34.2 hr

\(\mathbf{72.31\% }\)

\(\mathbf{91.12\% }\)

The 4060 Ti row is a per-epoch rate carried out to the full schedule, not a completed run, hence no accuracy. The 3060 row is the completed run: 72.31% top-1 / 91.12% top-5 over all 50,000 validation images, 300 epochs in 34.2 hours, one attempt, no restarts, no thermal pauses. DeiT-Ti without distillation publishes 72.2% / 91.1%, so this is \(+0.11\) and \(+0.02\) — on the paper, not past it in any meaningful sense. Per-epoch validation:

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation accuracy (\%)},
    xmin=0, xmax=301, ymin=0, ymax=95,
    xtick={0,50,100,150,200,250,300},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    every axis plot/.append style={line width=1pt, mark size=0.8pt},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(5,14.07) (10,30.64) (15,39.38) (20,44.78) (25,48.44) (30,51.03) (35,53.13) (40,54.89) (45,56.20) (50,57.36) (55,58.29) (60,59.14) (65,59.94) (70,60.61) (75,61.18) (80,61.85) (85,62.30) (90,62.82) (95,63.20) (100,63.62) (105,63.95) (110,64.41) (115,64.80) (120,65.20) (125,65.42) (130,65.74) (135,66.10) (140,66.44) (145,66.77) (150,66.99) (155,67.11) (160,67.49) (165,67.78) (170,68.08) (175,68.31) (180,68.57) (185,68.85) (190,69.04) (195,69.21) (200,69.40) (205,69.59) (210,69.88) (215,70.08) (220,70.30) (225,70.56) (230,70.78) (235,70.98) (240,71.19) (245,71.46) (250,71.57) (255,71.66) (260,71.78) (265,71.91) (270,71.99) (275,72.05) (280,72.12) (285,72.23) (290,72.29) (295,72.29) (300,72.31)
};
\addlegendentry{top-1}
\addplot[orange, mark=*, mark options={fill=orange}] coordinates {
(5,32.08) (10,54.58) (15,64.70) (20,70.06) (25,73.53) (30,75.88) (35,77.68) (40,79.02) (45,80.11) (50,81.05) (55,81.86) (60,82.46) (65,83.03) (70,83.57) (75,84.01) (80,84.39) (85,84.80) (90,85.13) (95,85.46) (100,85.79) (105,86.11) (110,86.45) (115,86.66) (120,86.93) (125,87.09) (130,87.36) (135,87.54) (140,87.70) (145,87.96) (150,88.10) (155,88.27) (160,88.45) (165,88.60) (170,88.76) (175,88.88) (180,89.01) (185,89.15) (190,89.30) (195,89.46) (200,89.50) (205,89.67) (210,89.73) (215,89.86) (220,90.05) (225,90.17) (230,90.36) (235,90.43) (240,90.52) (245,90.57) (250,90.69) (255,90.81) (260,90.88) (265,90.91) (270,90.94) (275,90.99) (280,91.03) (285,91.07) (290,91.09) (295,91.09) (300,91.12)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}

ViT-Tiny / ImageNet-1k validation accuracy per epoch (bf16, 4\(\times \) RTX 3060, CUDA), sampled every 5 epochs across the 300-epoch run and scored over all 50,000 validation images.

Where this still differs from the paper. The number matches. The recipe does not yet match exactly, and the honest list is short but not empty.

What matches. Architecture (patch16, \(d{=}192\), 12 blocks, 3 heads, 5,717,416 parameters); schedule (300 epochs, 5-epoch warmup, cosine to zero); optimizer (AdamW, weight decay \(0.05\), no decay on norms, biases, CLS or positional embedding); loss (cross-entropy with label smoothing \(0.1\)); the augmentation stack (Mixup \(0.8\), CutMix \(1.0\), rand-m9-mstd0.5-inc1 with the SolarizeAdd\(+\)Invert op-set and per-op apply probability \(0.5\), Random-Erase \(p{=}0.25\), repeated augmentation \(3\times \)); stochastic depth \(0.1\); and model EMA at \(0.99996\). Weight initialization now matches as well — every transformer Linear gets \(\mathrm{trunc\_ normal}(\sigma {=}0.02)\) and the patch-embed convolution PyTorch’s Conv2d default — which is what vitInit in the listing above selects. It is worth saying why that one mattered: Xavier-uniform scales as \(1/\sqrt{d}\) against timm’s fixed \(0.02\), so the generic path was \(1.8\times \) too wide at ViT-B, \(2.6\times \) at ViT-S and \(3.6\times \) at ViT-Ti, while the patch embed came out \({\sim }6\times \) too narrow (it divided by the output fan \(d\cdot p^2\) rather than the input fan \(c\cdot p^2\)). The smallest model was the worst case.

What does not match. Four things, in rough order of how much they could matter:

  1. Batch \(512\), not DeiT’s \(1024\). The learning rate is scaled consistently for it (\(5\times 10^{-4}\) at \(512\) is DeiT’s own \(\mathrm{lr} = 5\times 10^{-4}\cdot B/512\)), so this is a smaller-batch run of the same recipe rather than a mis-tuned one — but it is not the paper’s batch, and repeated augmentation interacts with batch size by construction.

  2. Gradient clipping at global norm \(1.0\) is ours. It is load-bearing: without it the model collapses to chance the moment warmup ramps past \({\sim }1.6\times 10^{-4}\). Whether DeiT itself clips is not verified in this repo. The config comment calls it “the DeiT default” and that claim has never been checked against DeiT’s main.py; until someone reads the reference implementation, treat it as an addition of ours.

  3. RandomResizedCrop and Random-Erasing are a from-scratch tf.data reimplementation, not timm’s code, so they agree by specification rather than bit-for-bit. Random-Erase fills with zero where timm fills with noise.

  4. Geometric RandAugment interpolates bilinearly, where timm resolves bicubic from the model’s data config; appendix A carries the measurement and the decision to leave it.

None of these is a bug, and the loss curve is textbook DeiT. But “matches the paper’s number” and “matches the paper” are different claims, and only the first is established here.

The further \(+2.3\) points to DeiT-Ti’s \(74.5\% \) headline are a different thing entirely, the distillation variant (a second “distillation token” trained against a strong CNN teacher), which we do not implement.

Phase 4: the verified trainer

Everything above this point is the phase-2 Lean\(\to \)JAX trainer. The phase-4 peer, which trains the same network through the proof-rendered StableHLO that §9.1 ran at Imagenette scale, exists and builds. It is apps/imagenette/MainViTImagenet.lean, built as lake build vit-imagenet-verified:

-- Identical backbone, 1000 classes instead of 10.
def vitImagenetVerified : VerifiedNetSpec where
  name       := "ViT-Tiny (ImageNet-1k)"
  slug       := "vitin"
  nClasses   := 1000
  data       := .imagenet
  shimScript := "generated_vit_tiny_imagenet_shim.py"
  layers     := [ ... the Imagenette stack, 1000-class head ... ]

-- 300 epochs, because that is the schedule the phase-2 number
-- above was measured on. Batch is 128 PER DEVICE, so four
-- replicas give the reference's global 512 exactly.
def vitImagenetConfig : VerifiedConfig where
  epochs    := 300
  batchSize := 128

The head is the only parameter shape that moves, from \(192 \times 10\) to \(192 \times 1000\), which takes the count from 5,526,346 to 5,717,416. The same three kernel #guards that hold ConvNeXt’s two specs together hold these: equal toSpecs.size, equal toSpecs.pop.pop, and a back! of (#[1000], 2).

The collectives are gated one way here, and it is worth saying which. tests/TestViTDpCheck.lean pins the data-parallel step against its single-device peer by handing both replicas the same rows, where the all-reduce mean is an identity. ViT has no BatchNorm, so that identity is exact rather than approximate. But a gate built on duplicated rows is structurally blind to a shard-offset bug, and the check that closes that hole works by giving the replicas genuinely different data. That one now covers the Imagenette vit spec, and it does not yet cover vitin. Two gates that sound alike are not one property, and this chapter has one and a half of them rather than ConvNeXt’s two.

The regularizer knobs are render variants, and the same combination is missing here as there. Weight-decay exclusion, grad clipping and stochastic depth are spelled wx, clip and drop, and vitin_adamdp128x4wxclipdrop carries all three at once on top of AdamW and the four-way all-reduce. EMA is a variant too (vitin_emadp128x4), but no committed artifact combines it with the other three. Mixup, CutMix and repeated augmentation never enter the graph at all, because they are data-side and ride the shim.

What it has not done is run. No phase-4 ImageNet result exists for this network, which is why every accuracy above comes from phase 2. What is measured is the throughput, and the schedule follows from it:

Box

ms/step

min/epoch

300 epochs

Val top-1

4\(\times \) 4060 Ti (CUDA)

\(224 \to 153\)

\(9.3 \to 6.4\)

\(\sim \)50 \(\to \) \(\sim \)35 h  (\(2.1 \to 1.5\) d)

TBD

4\(\times \) 3060 (CUDA)

TBD

The columns are the two arms of adamdp128x4wxclipdrop at \(128\) per device, so four cards give DeiT’s own global \(512\) and \(2{,}502\) steps per epoch — the reference’s own figure. fp32 is the default; the run will be bf16, so read the right-hand column, and it will also carry EMA, which vit-default-emabf16-4gpu names and which measures \(151\) ms — one under the arm above, so the shadow is free.

[TODO: run vit-imagenet-verified.]

What Part 1 has established

Every layer primitive shipped in Part 1’s trainers (MLP, CNN, ResNet, MobileNet, EfficientNet, ConvNeXt, ViT) has a machine-checked backward pass. The same VerifiedNetSpec / VerifiedConfig / trainAdamSched pipeline trains all of them with at most config-level changes. The MLIR each emits is between 7 KB (MLP) and 1,316 KB (EfficientNet-B0), and the lowerer compiles all of them the same way.

The bestiary in Part 2 then shows that every other architecture you’ve seen in modern deep learning composes from the same small set of primitives plus a handful of architecture-specific bundled layers. That list runs UNet, YOLO, DETR, Mask R-CNN, DCGAN, CycleGAN, Pix2Pix, DDPM, Stable Diffusion, VAE, AlphaGo, AlphaZero, MuZero, Mamba, BERT, GPT, Whisper, CLIP, LLaVA, SAM, SegFormer, DeepLab v3+, Nyström-former, QANet and Evoformer. Three rules (chain, additive fan-in, multiplicative fan-in). Five Jacobian tricks (diagonal, sparse Toeplitz, binary selection, rank-1 correction, outer product).

That’s the whole framework. Part 2’s bestiary takes the same set of primitives and tours how they compose into every well-known modern architecture, a survey of the field expressed in the proven kit, with a small library of bundled idioms for the patterns that recur.

9.7 Side quest: scaling to ViT-S and B

The axis. ViT scales on one axis where ConvNeXt scaled on two. Tiny, Small and Base are the same twelve blocks at a wider model dimension, with the MLP at \(4D\) throughout. Depth never moves.

model

shape

params

graph, 4 cards \(128\)/dev: fp32 \(\to \) bf16

ViT-Ti

\(12\) blocks, \(D{=}192\) / 3 heads

\(5.7\) M

\(171.6 \to 96.4\) ms  (\(1.78\times \))

ViT-S

\(12\) blocks, \(D{=}384\) / 6 heads

\(22{,}050{,}664\)

\(464.0 \to 245.5\) ms  (\(\mathbf{1.89\times }\))

ViT-B

\(12\) blocks, \(D{=}768\) / 12 heads

\(86{,}567{,}656\)

\(1295.6 \to 745.3\) ms  (\(1.74\times \))

Counts #guard against DeiT-S’s \(22.05\) M and DeiT-B’s \(86.57\) M. These are the graph’s times, all-reduce in and no trainer around them, at four replicas because S and B have no single-device render at all. All three run \(128\) per device, so four cards is DeiT’s own global \(512\) and the recipe’s \(5\times 10^{-4}\) is the rate that batch was set for — which took four #eval lines rather than a renderer feature, because the batch was never the obstacle.

A full schedule, four cards, \(300\) epochs, totals including \(37.5\) s per epoch of evaluation and checkpointing. Trainer steps, all measured on real ImageNet over \(40\) steps:

verified, 4 cards

global

steps/epoch

ms/step

300 epochs

ViT-Ti

\(512\)

\(2{,}502\)

\(224 \to 153\)

\(50 \to 35\) h  (\(2.1 \to 1.5\) d)

ViT-S

\(512\)

\(2{,}502\)

\(507 \to 296\)

\(109 \to 65\) h  (\(4.5 \to 2.7\) d)

ViT-B

\(512\)

\(2{,}502\)

\(1369 \to 799\)

\(289 \to 170\) h  (\(12.0 \to 7.1\) d)