Verified Deep Learning with Lean 4

5 ResNet-34

Deeper networks used to stop training

Before 2015, the dominant assumption in image classification was “deeper is better, up to a point.” The “up to a point” part was not metaphorical. If you took a 20-layer CNN that trained to 70% val accuracy and added 12 more convolutional layers, you didn’t get a 32-layer model with 72% accuracy. You got a 32-layer model that, after the same training budget, fit the training set worse than the 20-layer one did. The 32-layer model had strictly more capacity. It still trained worse. Something about deep stacks of convolutions made gradient flow degrade in a way that the optimizer couldn’t navigate around.

He et al. 2015 (arXiv:1512.03385) had the right observation: if the 12 extra layers in the deeper network were initialized to compute the identity, the 32-layer network would have at least the 20-layer’s performance, because it could replicate it exactly. So the question was not “can a deep network represent what a shallow one represents?” It provably could. The question was: why is optimizing 12 free-form layers to recover identity so much harder than just leaving identity there?

5.1 Run it first

Before any of the math, train the thing. Four commands and about fifty minutes of GPU time:

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

On one RTX 4060 Ti (CUDA 12.9), from runs/2026-09-12-r34-ablation-fp32-seeds/full_s2.log: seed 2 of the five that §5.6 averages, run by the ablation driver’s full arm, which is this binary’s render and recipe under LEAN_MLIR_SEED=2. XLA’s startup banner and the resident-parameter notices are removed, the network description line is wrapped, each interval is wrapped onto an indented continuation, and epochs 5 through 75 are elided:

[pjrt_ffi] XLA backend: PJRT 0.114, 1 device(s)
[pjrt_ffi] command buffers: enabled (CUDA default)
[pjrt_ffi] compiled verified_mlir/resnet34_adam_train_step.mlir
             (@resnet34_adam_train_step, 405 outputs, 1 replica) in 6127 ms
[pjrt_ffi] compiled verified_mlir/resnet34_fwd.mlir
             (@resnet34_fwd, 1 outputs, 1 replica) in 596 ms
[pjrt_ffi] compiled verified_mlir/resnet34_fwd_eval.mlir
             (@resnet34_fwd_eval, 1 outputs, 1 replica) in 293 ms
Real ResNet-34 on Imagenette 224² (7×7-s2 stem→3×3-s2 overlapping max pool→
  [3,4,6,3] blocks w/ batch-norm, He et al. option-B 1×1 projection shortcuts,
  no conv biases; 56→28→14→7→GAP→dense)
  via the VERIFIED renderer → XLA/PJRT → GPU
  xla/pjrt verified_mlir/resnet34_adam_train_step.mlir
  xla/pjrt verified_mlir/resnet34_fwd.mlir
  xla/pjrt verified_mlir/resnet34_fwd_eval.mlir
  train 9469, val 3925; bs 32, ResNet-34 adam
    (cosine+warmup 3ep, baseLR 0.001000), He init
  running-stats BN: 36 layers, 17024 stat floats → eval via @resnet34_fwd_eval
Epoch 1/80: loss=1.971035 lr=0.000333
  epoch 1: val_acc = 1239/3925 = 31.566879%  top5 = 2766/3925 = 70.471338%
    [95% CI 30.13–33.04]
Epoch 2/80: loss=1.553382 lr=0.000667
  epoch 2: val_acc = 1471/3925 = 37.477707%  top5 = 3123/3925 = 79.566879%
    [95% CI 35.98–39.00]
Epoch 3/80: loss=1.381944 lr=0.001000
  epoch 3: val_acc = 1880/3925 = 47.898089%  top5 = 3159/3925 = 80.484076%
    [95% CI 46.34–49.46]
Epoch 4/80: loss=1.311593 lr=0.001000
  epoch 4: val_acc = 1851/3925 = 47.159236%  top5 = 3547/3925 = 90.369427%
    [95% CI 45.60–48.72]
Epoch 76/80: loss=0.502131 lr=0.000007
  epoch 76: val_acc = 3536/3925 = 90.089172%  top5 = 3859/3925 = 98.318471%
    [95% CI 89.11–90.99]
Epoch 79/80: loss=0.502245 lr=0.000000
  epoch 79: val_acc = 3535/3925 = 90.063694%  top5 = 3859/3925 = 98.318471%
    [95% CI 89.09–90.96]
Epoch 80/80: loss=0.502085 lr=0.000000
  epoch 80: val_acc = 3535/3925 = 90.063694%  top5 = 3859/3925 = 98.318471%
    [95% CI 89.09–90.96]
done (trained ResNet-34 adam + cosine/warmup via packed threading).

Eighty epochs at about 35 seconds each, 47 minutes in all, and 90.06% top-1 with 98.32% top-5 on Imagenette’s 3,925-image validation split. This is one seed of five; the five land at \(89.99 \pm 0.32\) top-1 (§5.6), and that mean is the number every later chapter compares against.

The residual block: ask for the difference, not the function

Their fix was a one-line architectural change. Instead of asking each block to learn a function \(y = f(x)\), ask it to learn a function \(y = f(x) + x\). The block’s job is no longer to become the new representation. Its job is to learn what to add to the current representation. At initialization, with randomly-small \(f\), each block is approximately the identity, and a stack of 16 such blocks is approximately a stack of 16 identities, which is to say, approximately the input. The network starts out near-identity at every depth and only gradually takes on structure as \(f\) trains. The optimization problem becomes “what residual should each block add” instead of “what function should each block be,” and the former turns out to be dramatically easier.

The change to the architecture is one + sign. The change to the training dynamics is the difference between “18 layers is about as deep as we can go” and “152 layers trains stably, so let us also add another zero on the ImageNet leaderboard.”

Why the proof is a one-liner

From the framework’s perspective, what is a residual block? \(\mathrm{residual}(x) = f(x) + \mathrm{id}(x)\), a sum of two functions sharing the same input. The VJP of an additive fan-in is “send the upstream gradient through both branches and add” (Theorem 11); the VJP of \(\mathrm{id}\) is the identity on the gradient (Theorem 13), and the VJP of \(f\) is whatever the inner block’s machinery gives us. Composition is just \(\mathrm{vjpComp}\). So the residual VJP is the additive fan-in lemma, with \(\mathrm{id}\) plugged into one slot.

That is Theorem 58 below, and its proof is one line. The residual is the one theorem the idea needs, and that theorem is mechanical. The engineering revolution was not in the math. It was in the realization that this operation was the missing piece for training deep networks. The fact that the framework absorbs the new architecture without any new math is the value proposition: once the foundation rules are in place, the next decade of image recognition follows from composing them.

ResNet-34 is sixteen of these blocks

ResNet-34 (the smallest “deep” variant in the original paper) is built from 16 residual blocks arranged in four stages of depths 3, 4, 6, 3, at channel widths 64, 128, 256, 512. Each stage starts with a stride-2 downsample (halving spatial resolution) before its stack of stride-1 blocks. The stages are glued together by a stem (a 7\(\times \)7 conv at stride 2 plus a max-pool, taking 224\(\times \)224\(\times \)3 input to 56\(\times \)56\(\times \)64 features) and a head (global average pool plus a 512-to-10 dense layer). Total: 34 weight-bearing layers, 21.3M parameters.

Two pieces of this template are new and worth naming:

  1. The 7\(\times \)7 stride-2 stem. Earlier chapters’ networks operated at full input resolution. ResNet aggressively downsamples at the input so the bulk of compute happens at lower spatial resolution. The stem is one big conv that gets us from 224\(\times \)224 to 56\(\times \)56 in a single step.

  2. Global average pool instead of flatten-plus-dense. Chapters 3 and 4 ended in a .flatten followed by a dense layer (\(6272 \to 512\) on MNIST, \(128 \to 512\) on CIFAR) that held most of the network’s parameters. ResNet collapses the final 7\(\times \)7\(\times \)512 feature map to a 512-vector by spatial averaging, then runs a single 512-to-10 dense. No flatten layer. The architectural move alone drops the model from what would have been a \(\sim \)200M-parameter AlexNet-era spec down to 21.3M.

Every chapter after this one is a variation on this template. MobileNetV2 swaps in depthwise-separable blocks. EfficientNet adds squeeze-and-excitation to them. ConvNeXt swaps in its own block. ViT replaces the whole convolutional spine with patches and attention. The stem-stages-head template, and the residual fan-in pattern, persist.

5.2 The theorems

\begin{tikzpicture} [x=1pt,y=1pt, every node/.style={inner sep=1.50pt, font=\fontsize{7.00}{8.40}\selectfont\ttfamily}]
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=64.8pt, minimum height=12.7pt] at (152.3pt,8.9pt) {\hyperref[thm:residualHasVJP]{residualHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (152.3pt,197.9pt) {\hyperref[thm:resnet34FullHasVJP]{resnet34FullHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (152.3pt,170.9pt) {\hyperref[thm:resnet50FullHasVJP]{resnet50FullHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=85.8pt, minimum height=12.7pt] at (152.3pt,143.9pt) {\hyperref[thm:resnet50_whole_back]{resnet50\_whole\_back}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=77.4pt, minimum height=12.7pt] at (38.7pt,224.9pt) {\hyperref[thm:resnet34_step_tie]{resnet34\_step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (152.3pt,224.9pt) {\hyperref[thm:resnet34_loss_grad]{resnet34\_loss\_grad}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=77.4pt, minimum height=12.7pt] at (38.7pt,251.9pt) {\hyperref[thm:resnet50_step_tie]{resnet50\_step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=81.6pt, minimum height=12.7pt] at (152.3pt,251.9pt) {\hyperref[thm:resnet50_loss_grad]{resnet50\_loss\_grad}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=60.6pt, minimum height=12.7pt] at (152.3pt,116.9pt) {\hyperref[thm:resnet34_seal]{resnet34\_seal}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=60.6pt, minimum height=12.7pt] at (152.3pt,89.9pt) {\hyperref[thm:resnet50_seal]{resnet50\_seal}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=77.4pt, minimum height=12.7pt] at (152.3pt,62.9pt) {\hyperref[thm:resnet34_sync_tie]{resnet34\_sync\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=77.4pt, minimum height=12.7pt] at (152.3pt,35.9pt) {\hyperref[thm:resnet50_sync_tie]{resnet50\_sync\_tie}};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=45.6pt, minimum height=17.8pt, text=gray!60!black, align=center, font=\fontsize{5.50}{6.60}\selectfont\ttfamily] at (38.7pt,8.9pt) {\hyperref[thm:biPathHasVJP]{biPathHasVJP}\\(ch 1)};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=58.8pt, minimum height=17.8pt, text=gray!60!black, align=center, font=\fontsize{5.50}{6.60}\selectfont\ttfamily] at (38.7pt,116.9pt) {17 statements of\\ch 1, 2, 3, 4, 9};
  \draw[->, >=stealth, black!70, line width=0.40pt] (77.8pt,224.9pt) .. controls (84.8pt,224.9pt) and (92.3pt,224.9pt) .. (99.7pt,224.9pt) -- (111.1pt,224.9pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (77.8pt,251.9pt) .. controls (84.8pt,251.9pt) and (92.3pt,251.9pt) .. (99.7pt,251.9pt) -- (111.1pt,251.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (61.7pt,8.9pt) .. controls (75.0pt,8.9pt) and (92.4pt,8.9pt) .. (108.3pt,8.9pt) -- (119.6pt,8.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (46.9pt,126.0pt) .. controls (58.5pt,140.3pt) and (83.0pt,167.9pt) .. (109.4pt,183.9pt) .. controls (111.0pt,184.9pt) and (112.7pt,185.8pt) .. (114.5pt,186.7pt) -- (124.8pt,191.0pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (53.9pt,126.2pt) .. controls (67.7pt,134.9pt) and (89.4pt,147.9pt) .. (109.4pt,156.9pt) .. controls (112.0pt,158.1pt) and (114.8pt,159.2pt) .. (117.6pt,160.3pt) -- (128.4pt,164.1pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (68.4pt,123.8pt) .. controls (81.7pt,127.0pt) and (97.6pt,130.9pt) .. (112.0pt,134.4pt) -- (123.0pt,137.0pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (68.4pt,116.9pt) .. controls (81.3pt,116.9pt) and (96.6pt,116.9pt) .. (110.5pt,116.9pt) -- (121.9pt,116.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (68.4pt,109.9pt) .. controls (81.7pt,106.7pt) and (97.6pt,102.9pt) .. (112.0pt,99.4pt) -- (123.0pt,96.7pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (53.9pt,107.6pt) .. controls (67.7pt,98.9pt) and (89.4pt,85.8pt) .. (109.4pt,76.9pt) .. controls (112.0pt,75.7pt) and (114.8pt,74.6pt) .. (117.6pt,73.5pt) -- (128.4pt,69.6pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (46.9pt,107.8pt) .. controls (58.5pt,93.5pt) and (83.0pt,65.9pt) .. (109.4pt,49.9pt) .. controls (111.0pt,48.9pt) and (112.7pt,48.0pt) .. (114.5pt,47.1pt) -- (124.8pt,42.7pt);
\end{tikzpicture}
Theorem 58 Residual block VJP
✓
#

assume:

  1. \(B_f\) is a correct backward function for \(f\) (\(\mathsf{HasVJP}\, f\)) [hf]

  2. \(f\) is differentiable everywhere [hf_diff]

prove: \(\mathsf{HasVJP}\, (\mathrm{residual}\, f)\), where \(\mathrm{residual}\, f\, x = f(x) + x\), with backward \(B(x, dy) = B_f(x, dy) + dy\).

Proof ▶
  1. \(\mathrm{residual}\, f = \mathrm{biPath}\, f\, \mathrm{id}\).
    proof: Definitional.

  2. q.e.d.
    proof: Instantiate the additive fan-in VJP (Theorem 11) at \(g = \mathrm{id}\) (differentiable; its VJP is Theorem 13), with assumptions 1 and 2 supplying the \(f\) side. The identity’s backward contributes \(dy\) itself, so the composed backward is \(B_f(x, dy) + dy\) — the gradient floor that makes ResNets trainable.

The ladder.

From here every ImageNet chapter proves the same six statements about its network, and this is the first time all six appear. (i) The whole network at batch \(N\) has a VJP: the backward composed from the stages’ backwards is the \(\operatorname {pdiv}\)-contracted Jacobian of the forward. (ii) The hand-written backward chain the renderer emits is that backward. (iii) The emitted training step is the certified step: each of its gradient nodes denotes the certified batch gradient at the chain cotangent of the loss. (iv) Each node is the loss’s derivative in its parameter. (v) The backward is not the zero map, at one fixed weight setting — a seal. (vi) The data-parallel render’s mean over replicas is one device’s step on the global batch. Statements (i), (ii) and (iv) take the smooth-point hypotheses — \(\varepsilon {\gt} 0\) at every BatchNorm, the batch’s activations off every ReLU kink and every pool tie; (iii) and (vi) are identities of the emitted text and take none; (v) is at fixed weights.

assume:

  1. every BatchNorm’s \(\varepsilon {\gt} 0\) [R34PosB]

  2. at the batch’s activations, no ReLU pre-activation of the stem, of any residual block or of the pooled features is zero, and every stem-pool window is entirely zero or has one maximal cell [R34SmoothAtB]

prove: the backward composed from the eighteen stages’ backwards in reverse order, for the full-width ResNet-34 at training BatchNorm and batch \(N\) (resnet34ForwardBFull, \(3 \times 224 \times 224\)), equals the \(\operatorname {pdiv}\)-contracted Jacobian of the forward at every input, cotangent and index, generic in \(N\) and in the weights. The strict pool clause is one real batches do not meet; the parameter gradients of Theorem 63 are stated under one they do.

Proof ▶

A fold of the VJP chain rule (Theorem 10) over the eighteen stages — the stem, sixteen residual blocks (Theorem 58), the head — each at its running activation; see Proofs.resnet34ForwardBFullHasVJPAt_correct.

assume:

  1. the ResNet-34 conditions, at ResNet-50’s sixteen bottleneck blocks [R50PosB, R50SmoothAtB]

prove: the same for the full-width ResNet-50 (resnet50ForwardBFull) at every resolution \(q\) — the image side is \(32q\), so \(q = 7\) is the 224-pixel net and \(q = 5\) the 160-pixel one.

Proof ▶

As for ResNet-34, with the bottleneck in place of the basic block; see Proofs.resnet50ForwardBFullHasVJPAt_correct.

assume:

  1. \(\varepsilon _s {\gt} 0\), the stem ReLU off its kink and the stem pool’s per-example condition [h\(\varepsilon \)s, h_stem, h_pool]

  2. sixteen block maps \(b_1, \dots , b_{16}\), each with a VJP and differentiable at its running activation [hb1 … hb16]

prove: the hand-written eighteen-stage reverse pass every ResNet-50 artifact renders, with its blocks’ backwards filled by those witnesses, is the backward of \(\mathrm{head} \circ b_{16} \circ \dots \circ b_1 \circ \mathrm{stem}\), and so equals

\[ \sum _j \operatorname {pdiv}(\mathrm{head} \circ b_{16} \circ \dots \circ b_1 \circ \mathrm{stem})\, x\, i\, j \cdot dy_j \]

for every batch, cotangent and pixel. The blocks are opaque in the statement; that resnet50ForwardBFull is this composition at the sixteen bottlenecks is a separate shape check.

Proof ▶

The chain is the composed backward read stage by stage: the head’s and the stem’s endpoint ties, and each block’s backward in its slot; see Proofs.r50InputGradB_eq_r34B_full_vjp.

prove: each of the 110 emitted parameter gradients of the batched ResNet-34 step (resnet34_sgd_train_step.mlir and _adam_ at \(N = 32\), resnet34in_momdp64 at \(N = 64\)) denotes the certified batch gradient \(\sum _n \mathrm{Jacobian} \cdot \mathrm{cotangent}\) at the chain cotangent of the label-smoothed cross-entropy (\(\alpha _{\mathrm{ls}} = 0.1\), any target), generic in \(N\). Stated at either precision: the bf16 render resnet34in_momdp64bf16 emits a *GradBBf16 node at every convolution’s weight gradient, and at the identity rounding the renderers instantiate, each such node denotes its f32 peer.

Proof ▶

A fold over the 110 nodes: each is its layer’s weight or bias gradient at the cotangent the chain hands it, and the chain’s cotangents are the certified block backwards; see Proofs.ResNet34TieB.r34_net_tiedB.

assume:

  1. every BatchNorm’s \(\varepsilon {\gt} 0\) [R34PosB]

  2. at the batch’s activations, every ReLU is off its kink and every stem-pool window is entirely zero or has one maximal cell, up to twins — cells that read identical input patches [R34LossSmoothAtB]

  3. \(L\) is any loss of the logits, with gradient \(g\) there [hL]

prove: for each gradient node of Theorem 62, the node at the chain cotangent from \(g\) is \(\partial L/\partial \theta \) of resnet34ForwardBFull with that one parameter varied, at any batch size and number of classes. Instantiated at the batched label-smoothed cross-entropy, and at the stem with each tied window’s cotangent routed to any of its maximal cells, as the emitted select_and_scatter routes it.

Proof ▶

Each node is the step tie’s; its chain cotangent is \(g\) pulled back through the certified block backwards, one application of Theorem 15 per stage; see Proofs.ResNet34TieB.r34_net_lossGrad.

prove: each of the 161 parameter gradients of the batched ResNet-50 step denotes the certified batch gradient at the chain cotangent of a loss cotangent \(g\) left as a binder, generic in \(N\) and \(q\). Instantiated at the label-smoothed cross-entropy and at BCE-with-logits over \(N \cdot K\), the two losses the ResNet-50 artifacts train. Stated at either precision, as for ResNet-34.

Proof ▶

As for ResNet-34, over the three block kinds; see Proofs.ResNet50TieB.r50_net_tiedB.

assume:

  1. every BatchNorm’s \(\varepsilon {\gt} 0\) [R50PosB]

  2. the ResNet-34 smooth-point condition at the batch’s activations, up to twins at the stem pool [R50LossSmoothAtB]

  3. \(L\) is any loss of the logits, with gradient \(g\) there [hL]

prove: for each node of Theorem 64, the node at the chain cotangent from \(g\) is \(\partial L/\partial \theta \) of resnet50ForwardBFull with that one parameter varied, at any batch and number of classes. Instantiated at both losses of Theorem 64, and at the stem at any routing to a maximal cell, as for ResNet-34.

Proof ▶

As for ResNet-34; see Proofs.ResNet50TieB.r50_net_lossGrad.

prove: at the structural weights sealW and input sealX, batch 2, there is a class \(j_0\) and a pixel \(i_0\) at which the backward of the full-width ResNet-34 is non-zero: the identity of Theorem 59 has content, since the zero map does not satisfy it.

Proof ▶

Evaluate the composed backward at the witness and read off one non-zero coordinate; see Proofs.R34FullBSeal.sealX_backward_nontrivial.

prove: the same witness for ResNet-50, at both 224 and 160 pixels (\(0 {\lt} q \le 7\)).

Proof ▶

assume:

  1. \(R \ge 1\) replicas of \(N \ge 1\) images each, every BatchNorm synchronised [hR, hN]

  2. each replica’s loss cotangent is its shard of the global batch’s, scaled by \(R\) [hgs]

prove: the mean over replicas of each of the 110 all-reduced gradient nodes of the data-parallel render (resnet34in_momdp64 and its bf16 twin at \(\texttt{replicas} {\gt} 1\), loss \(\div B\) per replica) equals the single-device node of Theorem 62 at batch \(R \cdot N\) with loss \(\div (R \cdot B)\): the data-parallel step is one device’s step on the global batch. Instantiated at the label-smoothed cross-entropy.

Proof ▶

With synchronised statistics a replica’s forward is the global forward on its shard, so its node is the global node’s sum over that shard, scaled by \(R\); the mean of the \(R\) shard sums is the global sum; see Proofs.ResNet34SyncTieB.r34_net_syncTiedB.

prove: the same for ResNet-50’s 161 gradients, instantiated at the label-smoothed cross-entropy and at BCE with the committed divisors \(N \cdot K\) per replica and \((R \cdot N) \cdot K\) on one device.

Proof ▶

5.3 Example: ResNet-34 on Imagenette

ResNet-34 on 224\(\times \)224 Imagenette: 10 classes, real photographs, the transition point from “classroom dataset” to “actual image recognition.”

The architecture

The same vertical column as the previous chapters, now at production scale: a \(7 \times 7\) stride-2 stem and a max-pool drop the \(224 \times 224 \times 3\) input to \(56 \times 56 \times 64\), then four stages of residual blocks (depths \(3, 4, 6, 3\) at widths \(64, 128, 256, 512\)) do the work, and a global average pool replaces the flatten-plus-fat-dense head. The inset shows the one new piece: each residual block adds its input back, \(y = f(x) + x\).

\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.58cm, minimum width=5.4cm},
  io/.style     = {col, draw=blue!55!black,   fill=blue!8},
  convbn/.style = {col, draw=orange!65!black, fill=orange!12},
  pool/.style   = {col, draw=teal!60!black,   fill=teal!10},
  resid/.style  = {col, draw=violet!70!black, fill=violet!9, minimum height=0.66cm},
  gap/.style    = {col, draw=purple!60!black, fill=purple!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[convbn, below=0.18cm of input] (stem) {\textbf{ConvBN} stem $3\to64$, $7\times7$, /2};
  \node[pool,   below=0.18cm of stem]  (mp)   {\textbf{maxPool} $2\times2$ \;\; $112\to56$};
  \node[resid,  below=0.18cm of mp]    (s1)   {$3\times$ \textbf{Residual} $64\to64$ \;\; $56\times56$};
  \node[resid,  below=0.18cm of s1]    (s2)   {$4\times$ \textbf{Residual} $64\to128$, /2 \;\; $28\times28$};
  \node[resid,  below=0.18cm of s2]    (s3)   {$6\times$ \textbf{Residual} $128\to256$, /2 \;\; $14\times14$};
  \node[resid,  below=0.18cm of s3]    (s4)   {$3\times$ \textbf{Residual} $256\to512$, /2 \;\; $7\times7$};
  \node[gap,    below=0.18cm of s4]    (g)    {global avg pool \;\; $7\times7\times512 \to 512$};
  \node[head,   below=0.18cm of g]     (d)    {\textbf{Dense} $512\to10$ \;(identity)};
  \node[logits, below=0.18cm of d]     (out)  {Logits \;\; 10 classes, softmax-CE};
  \foreach \a/\b in {input/stem, stem/mp, mp/s1, s1/s2, s2/s3, s3/s4, s4/g, g/d, d/out}
     \draw[arr] (\a) -- (\b);
  \node[stage] at ($(s1.east) + (0.30,0)$) {stage 1};
  \node[stage] at ($(s2.east) + (0.30,0)$) {stage 2};
  \node[stage] at ($(s3.east) + (0.30,0)$) {stage 3};
  \node[stage] at ($(s4.east) + (0.30,0)$) {stage 4};
\end{tikzpicture}
\begin{tikzpicture} [
  >={Stealth[length=1.6mm]},
  every node/.style={font=\sffamily\scriptsize},
  box/.style  = {align=center, rounded corners=2pt, inner sep=3pt, minimum height=0.62cm, draw=violet!70!black, fill=violet!9},
  dot/.style  = {circle, draw=violet!70!black, fill=violet!12, inner sep=0pt, minimum size=0.42cm},
  term/.style = {font=\sffamily\scriptsize\itshape, inner sep=1pt},
  arr/.style  = {->, thick, gray!65, shorten >=1pt, shorten <=1pt},
  skip/.style = {->, thick, violet!70!black, shorten >=1pt},
]
  \node[term]                       (x)   {$x$};
  \node[box, right=0.55cm of x]     (f1)  {ConvBN, ReLU};
  \node[box, right=0.45cm of f1]    (f2)  {ConvBN};
  \node[dot, right=0.55cm of f2]    (sum) {$+$};
  \node[term, right=0.55cm of sum]  (y)   {ReLU $\to y$};
  \draw[arr] (x)  -- (f1);
  \draw[arr] (f1) -- (f2);
  \draw[arr] (f2) -- (sum);
  \draw[arr] (sum) -- (y);
  \draw[skip] (x.north) .. controls +(0,0.75) and +(0,0.75) .. (sum.north)
        node[midway, above, term, violet!70!black] {identity skip ($+\,x$)};
  \node[term, below=0.18cm of f2, gray!55!black] {one residual block: \; $y = f(x) + x$};
\end{tikzpicture}

The verified trainer, which is the pattern every later chapter reuses

This is the reference configuration; Chapters 6 through 9 each swap the layers list and change almost nothing else.

import LeanMlir.Verified.NetsCore
import LeanMlir.Verified.Train

-- 1. The network. A VerifiedNetSpec, not a NetSpec: `slug` names the
--    committed render, and `bnChannels` drives BN statistic threading.
def resnet34Verified : VerifiedNetSpec where
  name     := "ResNet-34"
  slug     := "resnet34"
  inC      := 3
  imageH   := 224
  imageW   := 224
  data     := .imagenette
  layers   := [
    .convBnNB 3 64 7 2,          -- 7x7-s2 stem, no conv bias   224->112
    .maxPool 3 2,                -- He et al. 3x3-s2 OVERLAPPING 112->56
    .residualStage  64  64 3 1,  -- stage 1, 3 blocks            @56
    .residualStage  64 128 4 2,  -- stage 2, downsample + 3      56->28
    .residualStage 128 256 6 2,  -- stage 3, downsample + 5      28->14
    .residualStage 256 512 3 2,  -- stage 4, downsample + 2      14->7
    .globalAvgPool,              -- replaces flatten + fat dense
    .dense 512 10 ]
  blurb := "Real ResNet-34 on Imagenette 224² (7×7-s2 stem->3×3-s2 overlapping
            max pool->[3,4,6,3] blocks w/ batch-norm, He et al. option-B 1×1
            projection shortcuts, no conv biases; 56->28->14->7->GAP->dense)
            via the VERIFIED renderer -> %LOWERER% -> GPU"
  bnChannels := #[64, 64,64, ... ]   -- all 36, in forward order

-- 2. The schedule. Everything the optimizer needs beyond the render.
def resnet34AdamConfig : VerifiedConfig where
  epochs    := 80
  batchSize := 32

-- 3. The entry point: AdamW, cosine with a 3-epoch warmup, baseLR 1e-3.
--    Variant, batch and rate are overridable; the defaults are Run it first's.
def runResnet34Adam (argv : List String) : IO Unit := do
  let variant := (← IO.getEnv "LEAN_MLIR_VARIANT").getD "adam"
  let bs      := ((← IO.getEnv "LEAN_MLIR_BATCH").bind (·.toNat?)).getD
                   resnet34AdamConfig.batchSize
  let baseLR := match (← IO.getEnv "LEAN_MLIR_BASE_LR_U").bind (·.toNat?) with
    | some u => u.toFloat * 1e-6     -- micro-units: 100000 is 0.1
    | none   => 0.001
  resnet34Verified.toNet.trainAdamSched
    { resnet34AdamConfig with batchSize := bs }
    (argv.head?.getD "data") baseLR 0.9 0.999 3 variant

The one thing in that listing to carry forward is that the recipe is arguments, not architecture. The learning rate, the momentum pair, the warmup length and the variant string are all passed to the driver; none of them is a property of the network. Swapping AdamW for momentum is a different argument against the same proven gradient, which is what makes §5.6’s ablation a measurement of the recipe rather than of the model — and what Chapter 4 used to put three optimizers on one net.

The schedule. Chapter 4 trained at a constant rate. Annealing the rate to zero on a cosine is worth about five points here (\(-5.00 \pm 2.60\) in §5.6), the largest contribution of any knob in the recipe except augmentation itself, and more than the optimizer, the warmup, the weight decay and the label smoothing combined. It buys that by taking large steps while the loss surface is still coarse and small ones once it is not, which is the reason the warmup exists at the other end and the reason a constant rate has to be a compromise between the two.

This is the first chapter that uses the full production recipe (AdamW + cosine + warmup + weight decay + augmentation + label smoothing). Also the first chapter where .globalAvgPool replaces the flatten-plus-fat-dense head. Global average pooling (GAP) collapses each \(C \times H \times W\) feature map to a length-\(C\) vector by averaging over the spatial dimensions,

\[ \mathrm{gap}(x)_c \; =\; \frac{1}{HW} \sum _{i, j} x_{c, i, j}, \]

so the classifier head sees one summary scalar per channel instead of the full \(C \cdot H \cdot W\) flattened activation.

GAP is not ResNet’s invention. It comes from Network in Network (Lin et al. 2013, arXiv:1312.4400), which proposed it precisely as a replacement for the fully-connected layers at the top of a convolutional net: one feature map per class, averaged, fed straight to the softmax, with no fully-connected parameters to overfit. Inception-v1 (GoogLeNet, Szegedy et al. 2014, arXiv:1409.4842) adopted it a year later — dropping the fully-connected stack for an average pool and a single linear classifier — and reported it worth about \(0.6\% \) top-1 on its own. By 2015 it was standard equipment, which is why ResNet uses it without argument. We’ll see GAP again as the “Squeeze” step inside the squeeze-and-excitation block in Chapter 7, and at the head of every post-2015 vision architecture in the bestiary.

Results

§5.1 has the run: 80 epochs on one RTX 4060 Ti, finishing at 90.06% top-1 and 98.32% top-5 on Imagenette’s 3,925-image validation split, one seed of the five that average \(89.99 \pm 0.32\). That is the going rate for a scratch-trained ResNet-34 at this resolution and data size. From the log:

  • Per-step time is about 120 ms at batch 32, 295 steps to the epoch, dominated by the four .residualStage groups where the \(3 \times 3\) convolutions at 64, 128, 256 and 512 channels are. The first stage alone, at 64 channels on the \(56 \times 56\) grid, is \(3 \times 3 \times 64 \times 64\) multiply-accumulates per output location, about \(1.2 \times 10^8\) per convolution, six convolutions in the stage, times the batch, and the backward costs about twice the forward again.

  • Final loss plateaus near 0.50, not zero. That’s the label-smoothing floor: with \(\epsilon = 0.1\) and 10 classes, the minimum possible cross-entropy against a smoothed target is \(-0.9 \log 0.9 - 0.1 \log (0.1/9) \approx 0.485\). The 0.50 value at epoch 80 means the model has essentially converged. More epochs would mostly just extend training time without budging the loss.

  • BN layers: 36. Every residual block contains two normalized convolutions, and there are 16 blocks across the four stages (3+4+6+3): 32. The projection shortcuts that open stages 2–4 carry one each, and the stem one, for 36 total. Each one proves its own VJP via Theorem 49 and composes with the rest of the network via Theorem 10.

5.4 MLIR: Residual

What is already proven. A residual block is \(\mathrm{residual}(x) = f(x) + x\), an additive fan-in, where \(f\) is the conv \(\to \) BN \(\to \) relu \(\to \) conv \(\to \) BN inner block. As Theorem 58 states, its reverse-mode derivative is the additive-fan-in rule with the identity in one slot: “send the upstream gradient through both branches and add”. residualHasVJPAt proves exactly this at the evaluation point (a one-line proof: the fan-in lemma with the identity’s VJP plugged into one slot), and the inner \(f\)’s VJP is the convolution and BatchNorm backward (Theorems 34, 49). The whole block, and a tower of sixteen of them, composes by the chain rule vjpCompAt (Theorem 10).

The gap and how we close it. The residual fan-in is the structural heart of a deep ResNet. The emitted backward is given a denotation valued in the proofs’ own tensor type, shown equal to residualHasVJPAt’s backward, and the structural move is visible in the emitted text. The forward saves \(\texttt{add} = f(x) + x\) and the block output \(\texttt{relu}(\texttt{add})\). The backward first pushes the incoming cotangent back through that relu (a compare/select at the post-add, giving %dadd), and then, and this is the fan-in, sends %dadd through both branches and adds (the inner block’s forward and \(f\)-backward elided, as they are the conv/BN nodes of Chapters 3–4):

// forward (saved): %out = f(%x)  [conv-BN-relu-conv-BN, Ch. 4-5]
//                  %add = %out + %x
%madd = stablehlo.compare GT, %add, %zc
          : (tensor<1x2x4x4xf32>, tensor<1x2x4x4xf32>)
            -> tensor<1x2x4x4xi1>
%dadd = stablehlo.select %madd, %dOut, %zc
          : tensor<1x2x4x4xi1>, tensor<1x2x4x4xf32>
// %dF = f.backward(%dadd)  [conv/BN/relu input-VJP chain, Ch. 4-5]
%dx = stablehlo.add %dF, %dadd : tensor<1x2x4x4xf32>
return %dx : tensor<1x2x4x4xf32>

Read it against the theorem: %dadd is the cotangent at the fan-in, and it appears twice in the last two lines, once as the input to \(f\)’s backward (%dF, the gradient through the residual function) and once added directly (%dx = %dF + %dadd, the gradient straight down the identity skip). That single stablehlo.add is “send the gradient through both branches and add”: the skip term is the same %dadd the block term started from, which is why the gradient survives all sixteen blocks instead of attenuating away. The inner \(f\) backward that computes %dF, the convolution and BatchNorm input-VJPs, is bridged op by op exactly as in Chapters 3 and 4. Only the fan-in is new here.

Caveats.

  • The fan-in add is unconditional, because addition is linear, so that bridge holds at every input.

  • The post-add ReLU is a smooth-point bridge (no pre-activation exactly zero), and the two BatchNorms inside \(f\) carry their own \(\epsilon {\gt} 0\) conditions (Theorem 49).

  • Representative scale, a two-channel block at \(4\times 4\). The same %dadd fan-in repeats for all sixteen blocks.

5.5 What’s in the production recipe?

Six ingredients first appear here, none of which are layers. They all live in TrainConfig or training code. They don’t touch the network; what each one is worth is measured in §5.6.

Adam (Kingma & Ba, 2014, arXiv:1412.6980). Replaces vanilla SGD. Maintains per-parameter running estimates of the first and second moments of the gradient and uses them for an adaptive per-parameter learning rate. Much faster convergence than SGD on most tasks, much more forgiving of learning-rate choice.

Cosine learning-rate schedule (Loshchilov & Hutter, 2016, arXiv:1608.03983). After warmup, the learning rate decays from its peak to near-zero following \(\mathrm{lr}(t) = \mathrm{lr}_{\max } \cdot \tfrac {1}{2}(1 + \cos (\pi t / T))\). Smoother than step schedules, and consistently produces slightly better final losses in practice. Paired with warmup and progressive resizing (Howard et al., 2018), this was the winning recipe at Stanford’s DAWNBench in 2018. The lineage traces back to Leslie Smith’s cyclical learning rates (Smith 2015, arXiv:1506.01186). Popularized for ImageNet-scale training by He et al. 2018’s “Bag of Tricks” (arXiv:1812.01187).

Warmup (Goyal et al., 2017, arXiv:1706.02677). Linearly ramps the learning rate from zero to its peak over the first few epochs (3 for ResNet-34). Stabilizes early training when gradients are still reorganizing randomly-initialized weights. Becomes essential for larger models and larger batches; Chapter 9’s ViT-Tiny trains with one.

Weight decay. Adds a small L2 shrink to all trainable weights each step: \(w \leftarrow w - \eta \lambda w\), with \(\lambda = 10^{-4}\) here. Regularizer, discourages weights from growing without bound. Loshchilov & Hutter’s “decoupled” variant (AdamW, 2017, arXiv:1711.05101) is what actually ships in modern pipelines, and it’s what TrainConfig.weightDecay implements.

Augmentation. Our pipeline does random crops (\(256 \to 224\)) and random horizontal flips on Imagenette, hflip only on CIFAR, and nothing on MNIST. Lives in the data pipeline, not the network. We will explore data augmentation strategies in upcoming chapters.

Label smoothing (Szegedy et al., 2016, arXiv:1512.00567). Replaces the one-hot target with a smoothed \((\varepsilon / (K-1), \ldots , 1 - \varepsilon , \ldots )\). Stops the network from producing arbitrarily large logits on the correct class, which keeps training stable and improves calibration. Explains the 0.50 loss floor in the Results section above: \(-0.9 \log 0.9 - 0.1 \log (0.1/9) \approx 0.485\).

Every chapter after this one uses the same six ingredients. The NetSpec changes, and the recipe stays.

5.6 Ablation: what each ingredient contributes

So how much does each ingredient actually earn? The full recipe and six leave-one-out variants, ResNet-34 on Imagenette, 80 epochs each. Every variant keeps the recipe intact except for the one component it names, so its difference from the full recipe measures that component’s contribution given everything else is present — not the order-dependent story an additive ladder tells.

\begin{tikzpicture} 
\pgfplotsset{
  ablationaxis/.style={
    width=0.45\linewidth, height=6.4cm,
    xmin=-8.4, xmax=1.4,
    symbolic y coords={full,nowd,nowarm,nols,noadam,nocos,noaug},
    % ⚠ ytick EXPLICIT, not `ytick=data`: the latter emits a tick only for categories that
    % have a plotted point, so the empty `gap` row got none and every label after it slid
    % up one row — the augmentation point appeared on an unlabelled line.
    ytick={full,nowd,nowarm,nols,noadam,nocos,noaug}, y dir=reverse,
    xlabel={$\Delta$ top-1 vs full recipe (points)},
    tick label style={font=\small}, label style={font=\small},
    title style={font=\small},
    grid=major, grid style={gray!14},
    scatter/use mapped color={draw=black,fill=black},
  }
}
% ── fp32 ───────────────────────────────────────────────────────────────────
\begin{axis}[ablationaxis, title={fp32, 5 seeds},
  yticklabels={full,$-$weight decay,$-$warmup,$-$label smoothing,
               $-$AdamW,$-$cosine,$-$augmentation}]
  \draw[black!70]        ({axis cs:0,full}|-{rel axis cs:0,0})
                      -- ({axis cs:0,full}|-{rel axis cs:0,1});
  % Five seeds per arm (runs/2026-09-12-r34-ablation-fp32-seeds/RESULTS.md): the point is the
  % mean difference from `full`, the bar the 95% t-interval on the PAIRED difference (arms
  % sharing a seed share init and data order). No band: the bar is the resolution.
  \addplot[only marks, mark=*, mark size=2.0pt, blue!70!black,
           error bars/.cd, x dir=both, x explicit,
           error bar style={blue!70!black, line width=0.6pt},
           error mark options={rotate=90, blue!70!black, mark size=1.6pt}] coordinates {
    (0,full) +- (0,0) (-0.02,nowd) +- (0.61,0) (-0.55,nowarm) +- (0.46,0)
    (-0.69,nols) +- (0.42,0) (-2.27,noadam) +- (0.59,0) (-5.00,nocos) +- (2.60,0)
    (-7.33,noaug) +- (0.49,0)};
\end{axis}
% ── bf16 ───────────────────────────────────────────────────────────────────
\begin{axis}[ablationaxis, title={bf16, 5 seeds}, yticklabels={{},{},{},{},{},{},{}},
  at={(0.47\linewidth,0)}, anchor=south west]
  \draw[black!70]        ({axis cs:0,full}|-{rel axis cs:0,0})
                      -- ({axis cs:0,full}|-{rel axis cs:0,1});
  % Five seeds per arm (runs/2026-09-11-r34-ablation-bf16-seeds/RESULTS.md): the point is the
  % mean difference from `full`, the bar the 95% t-interval on the PAIRED difference (arms
  % sharing a seed share init and data order). No band: the bar is the resolution.
  \addplot[only marks, mark=*, mark size=2.0pt, orange!80!black,
           error bars/.cd, x dir=both, x explicit,
           error bar style={orange!80!black, line width=0.6pt},
           error mark options={rotate=90, orange!80!black, mark size=1.6pt}] coordinates {
    (0,full) +- (0,0) (0.14,nowd) +- (0.45,0) (-0.82,nowarm) +- (0.62,0)
    (-0.50,nols) +- (0.62,0) (-2.21,noadam) +- (0.33,0) (-4.78,nocos) +- (2.74,0)
    (-7.11,noaug) +- (0.38,0)};
\end{axis}
\end{tikzpicture}

Each point is plotted as its difference from the full recipe rather than as an accuracy, so the line at zero is the baseline and distance from it is what the ingredient contributes. Each arm was trained five times from five seeds; the point is the mean difference and the bar its 95% interval, paired by seed, since arms sharing a seed share initialization and data order. An arm whose bar crosses zero is not placed, however suggestive its mean. The full recipe lands at \(89.99 \pm 0.32\) (fp32) and \(89.98 \pm 0.15\) (bf16). Rows are sorted by fp32 effect.

The two panels are the same seven arms in the two precisions, and the reason to draw them side by side is that a real effect should not move. None does: every arm’s fp32 mean falls inside the bf16 interval and every bf16 mean inside the fp32 one. Four arms are resolved in both. Label smoothing is resolved in fp32 (\(-0.69 \pm 0.42\)) but not bf16 (\(-0.50 \pm 0.62\)), where the means agree and only the interval is wider. Weight decay is resolved in neither (\(-0.02 \pm 0.61\), \(+0.14 \pm 0.45\)): ten runs do not place it, and the honest reading is that here it earns nothing measurable.

The cosine bar is the widest in both precisions because the constant-rate arm finishes wherever its noisy trajectory happens to be at epoch 80: fp32 seeds spread \(82.0\)–\(86.8\) (sd \(1.93\)), bf16 \(82.6\)–\(87.9\) (sd \(2.18\)). Its contribution is large and imprecise, and both precisions agreeing on how imprecise says the width belongs to the arm, not to either number format.

What the optimizer row is really measuring. Reading the \(-\)AdamW gap as “what the adaptive optimizer buys” assumes its replacement got a rate that suits it. Heavy-ball at \(\mu = 0.9\) multiplies the effective step by \(1/(1-\mu ) = 10\), so the comparison that isolates momentum is against plain SGD at ten times the rate — \(0.1\), not \(0.01\) — where both take the same size step and differ only in whether a velocity carries it.

\begin{tikzpicture} 
% ── left: the ladder ───────────────────────────────────────────────────────
\begin{axis}[
  width=0.42\linewidth, height=4.6cm,
  % ⚠ Deliberately a TIGHTER x range than the two ablation panels above. Those span
  % -8.4 to +1.4 because augmentation reaches -7.3; here the whole ladder lives inside
  % three points, and at the wider scale the 0.40 that separates the two lower rungs is
  % 2% of the axis width -- i.e. invisible, which is the one thing this figure exists to show.
  xmin=-3.4, xmax=0.8,
  symbolic y coords={full,mom,sgd10}, ytick={full,mom,sgd10},
  y dir=reverse,
  yticklabels={AdamW lr .001,momentum lr .01,SGD lr .1},
  xlabel={$\Delta$ top-1 vs full (points)},
  tick label style={font=\small}, label style={font=\small},
  title style={font=\small}, title={endpoints, 5 seeds},
  grid=major, grid style={gray!14}]
  \draw[black!70] ({axis cs:0,full}|-{rel axis cs:0,0})
               -- ({axis cs:0,full}|-{rel axis cs:0,1});
  % Five seeds per rung, runs/2026-09-12-r34-ablation-fp32-seeds/RESULTS.md. `mom` IS the
  % ablation's `noadam` arm -- the same artifact, not a re-run -- so only `sgd10` was new work.
  % Bars are the paired 95% interval on the difference from `full`.
  % ⚠⚠ These two lower bars OVERLAP while the PAIRED difference between the two rungs,
  % +0.403 ± 0.162, EXCLUDES zero. That is not a contradiction and the prose says so: the
  % arms share seeds, so their difference is far better determined than either one's
  % distance from `full`. Do not "fix" this by widening the bars or dropping the claim.
  % ⚠ `full` carries +- (0,0) because this axis is a DIFFERENCE from `full`: per seed that
  % difference is identically zero, so its interval is exactly zero. It is not an unmeasured
  % point. `full`'s own spread is 89.99 ± 0.32 (sd 0.26, five seeds) and it is already
  % INSIDE every other bar, since each paired difference subtracts that seed's own `full`.
  % Plotting ±0.32 here would be an absolute CI on a difference axis, and would double-count.
  \addplot[only marks,mark=*,mark size=2.2pt,blue!70!black,
           error bars/.cd, x dir=both, x explicit,
           error bar style={blue!70!black, line width=0.6pt},
           error mark options={rotate=90, blue!70!black, mark size=1.6pt}]
    coordinates {(0,full) +- (0,0) (-2.27,mom) +- (0.59,0) (-1.86,sgd10) +- (0.57,0)};
\end{axis}
% ── right: the trajectories ────────────────────────────────────────────────
\begin{axis}[
  width=0.47\linewidth, height=4.6cm, at={(0.45\linewidth,0)}, anchor=south west,
  xlabel={Epoch}, ylabel={Val top-1 (\%)},
  xmin=0,xmax=81,ymin=10,ymax=95, xtick={0,20,40,60,80},
  tick label style={font=\small}, label style={font=\small},
  % One run per optimizer -- these are the original curves, not per-seed means. The claim
  % they carry is about SHAPE (the two lower curves never separate), which one run shows.
  title style={font=\small}, title={trajectories, one run each},
  legend style={font=\scriptsize,at={(0.98,0.03)},anchor=south east,draw=gray!40},
  legend cell align={left}, grid=major, grid style={gray!14},
  every axis plot/.append style={line width=0.7pt, mark=none}]
  \addplot[black!75]      coordinates {(1,24.69) (2,24.89) (3,45.91) (4,50.06) (5,49.81) (6,66.68) (7,57.43) (8,66.39) (9,72.36) (10,60.05) (11,55.06) (12,75.36) (13,73.86) (14,73.53) (15,81.10) (16,69.22) (17,63.34) (18,70.09) (19,76.76) (20,79.59) (21,71.67) (22,69.89) (23,79.46) (24,82.98) (25,78.29) (26,70.50) (27,81.43) (28,82.90) (29,77.99) (30,82.24) (31,82.11) (32,85.61) (33,85.76) (34,85.48) (35,83.36) (36,86.04) (37,86.39) (38,82.57) (39,85.55) (40,87.16) (41,85.04) (42,85.15) (43,86.37) (44,87.39) (45,86.19) (46,86.88) (47,88.13) (48,88.03) (49,87.67) (50,87.24) (51,86.98) (52,89.15) (53,88.41) (54,89.68) (55,88.97) (56,88.54) (57,88.71) (58,89.40) (59,89.30) (60,89.10) (61,88.87) (62,90.04) (63,89.78) (64,89.61) (65,90.11) (66,90.01) (67,90.19) (68,90.19) (69,90.11) (70,90.19) (71,90.19) (72,90.19) (73,90.17) (74,90.37) (75,90.55) (76,90.34) (77,90.42) (78,90.42) (79,90.47) (80,90.42)};   \addlegendentry{AdamW}
  \addplot[teal!60!black] coordinates {(1,22.52) (2,41.99) (3,53.02) (4,59.03) (5,65.12) (6,67.77) (7,68.38) (8,70.78) (9,74.55) (10,67.64) (11,69.76) (12,75.80) (13,73.89) (14,78.52) (15,79.39) (16,79.11) (17,79.29) (18,79.80) (19,80.48) (20,78.19) (21,78.09) (22,80.10) (23,82.83) (24,82.78) (25,80.33) (26,84.15) (27,80.59) (28,83.67) (29,83.92) (30,84.79) (31,85.43) (32,86.17) (33,86.01) (34,86.24) (35,85.27) (36,86.17) (37,86.62) (38,87.13) (39,86.62) (40,87.13) (41,86.98) (42,87.97) (43,87.18) (44,87.52) (45,87.29) (46,87.64) (47,87.69) (48,87.95) (49,87.52) (50,87.34) (51,87.75) (52,87.77) (53,88.23) (54,88.10) (55,87.85) (56,88.20) (57,87.97) (58,87.82) (59,87.95) (60,88.03) (61,87.80) (62,88.18) (63,88.13) (64,88.10) (65,88.28) (66,88.18) (67,88.31) (68,88.10) (69,88.05) (70,88.18) (71,88.18) (72,88.23) (73,88.05) (74,88.23) (75,88.31) (76,88.18) (77,88.28) (78,88.18) (79,88.10) (80,88.13)};    \addlegendentry{momentum .01}
  \addplot[blue!65!black] coordinates {(1,16.89) (2,27.26) (3,58.60) (4,50.37) (5,58.52) (6,63.11) (7,70.14) (8,69.83) (9,71.36) (10,51.80) (11,66.47) (12,73.61) (13,65.53) (14,70.09) (15,68.20) (16,74.04) (17,79.46) (18,77.66) (19,78.06) (20,80.97) (21,80.71) (22,83.44) (23,81.12) (24,75.36) (25,83.77) (26,81.02) (27,81.12) (28,82.42) (29,82.39) (30,84.92) (31,79.26) (32,84.64) (33,84.28) (34,84.92) (35,85.73) (36,83.06) (37,85.96) (38,82.45) (39,82.42) (40,86.37) (41,85.30) (42,85.61) (43,86.55) (44,86.80) (45,86.45) (46,87.34) (47,87.39) (48,87.21) (49,86.65) (50,87.13) (51,87.87) (52,87.87) (53,87.92) (54,87.36) (55,87.49) (56,87.64) (57,87.49) (58,87.69) (59,87.39) (60,87.85) (61,87.52) (62,87.77) (63,87.87) (64,87.46) (65,87.49) (66,87.57) (67,87.41) (68,87.49) (69,87.41) (70,87.46) (71,87.44) (72,87.75) (73,87.64) (74,87.69) (75,87.67) (76,87.80) (77,87.54) (78,87.72) (79,87.57) (80,87.82)};  \addlegendentry{SGD .1}
\end{axis}
\end{tikzpicture}

At a matched step, plain SGD does not merely keep up with momentum — it finishes slightly ahead, by a paired \(+0.403 \pm 0.162\) with all five seeds agreeing in sign. The two lower bars overlap while that difference is resolved, which is no contradiction: the arms share seeds, so their difference is better determined than either rung’s distance from the baseline. The baseline carries no bar for the same reason — its difference from itself is zero on every seed, and its own spread sits inside the other two bars. The trajectories say the same about shape: the two never separate across the eighty epochs.

Momentum is therefore not doing something a velocity-free step cannot; it is taking a bigger one, slightly less well. The \(-2.27 \pm 0.59\) charged to “removing AdamW” is almost entirely the \(10\times \) momentum supplies for free: hold the effective step fixed and the gap closes to \(-1.86 \pm 0.57\), the remainder running the other way.

That is this ablation’s own limit: every non-adaptive row’s difference is partly a claim about how well its learning rate suits it, and an adaptive optimizer’s insensitivity to that rate hides the dependence. The figure measures the recipe as tuned, not its ingredients in isolation.

Augmentation is the largest single contribution (\(-7.33 \pm 0.49\) fp32, \(-7.11 \pm 0.38\) bf16; three times the next-largest knob). Imagenette has 9,469 training images for a 21.3M-parameter network, so without the random crop and flip the model overfits hard. Most of what the rest of the recipe gets credit for is augmentation.

5.7 ImageNet recipe

The Imagenette runs above all trained on \(\sim \)9.5K images for 80 epochs. This section is the same 21.8M-parameter network on 1.28M images and 1000 classes, trained on the standard 90-epoch schedule. The architecture is unchanged. Only the dataset, the output head width, and the training schedule differ.

This section runs it twice. The reference is the Lean\(\to \)JAX trainer of §C.3.2: JAX traces the generated program and hands XLA the graph. The verified path hands XLA pretty(provenGraph) — the rendered artifact the proofs reason about — through the PJRT C API, with no Python at run time. Same architecture, same recipe, same data loader, same compiler; the only difference is who wrote the StableHLO. Every ImageNet section reports the reference first, the verified path second, and then the two side by side.

The recipe in full: SGD with momentum \(0.9\) at batch 256, cosine LR with a 5-epoch warmup from peak \(0.1\), label smoothing \(0.1\), Inception-style random-resized-crop through TensorFlow’s sampler (\(10\)–\(100\% \) of the image area at a \(\frac{3}{4}\)–\(\frac{4}{3}\) aspect ratio, resized to \(224\times 224\)) plus a horizontal flip, and \(\ell _2\) weight decay \(10^{-4}\). SGD with momentum and the weight decay are the original paper’s; the cosine schedule, the warmup, the label smoothing and the random-resized-crop are the “bag of tricks” polish that became standard after 2018.

The reference (JAX)

The reference trainer is jax/MainResnetImagenet.lean, built as lake build resnet34-imagenet, with .imagenet streamed through tfds. It runs in bf16 — the convolutions cast to bfloat16 against fp32 master weights, the classifier head left in fp32 — which is a throughput choice rather than a fidelity one, and is why its column is the faster of the two below.

GPU

Precision

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 3060 (CUDA)

bf16

\(\sim \)9.9 min

90

\(\sim \)14.8 hr

\(\mathbf{74.14\% }\)

\(\mathbf{91.83\% }\)

Global batch 256 as \(4 \times 64\) replicas; the final checkpoint scored over all \(50{,}000\) validation images through an fp32 forward, as the verified path and timm score, and the run’s own bf16 evaluation lands within \(0.01\) of it.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation accuracy (\%)},
    xmin=0, xmax=91, ymin=0, ymax=100,
    xtick={0,15,30,45,60,75,90},
    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=1pt},
    title style={font=\small},
    title={2018 recipe, 90 epochs --- reference},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(1,11.63) (2,22.29) (3,32.31) (4,30.19) (5,35.50) (6,37.22) (7,41.93) (8,39.35) (9,40.69) (10,41.59) (11,42.01) (12,46.38) (13,44.95) (14,45.73) (15,45.32) (16,44.48) (17,41.96) (18,43.56) (19,46.13) (20,43.86) (21,45.83) (22,41.70) (23,47.66) (24,44.46) (25,46.88) (26,45.37) (27,48.26) (28,46.01) (29,47.41) (30,49.68) (31,49.06) (32,48.03) (33,50.76) (34,48.16) (35,49.75) (36,51.55) (37,48.26) (38,50.97) (39,52.25) (40,50.67) (41,52.39) (42,51.48) (43,51.58) (44,50.70) (45,50.21) (46,54.93) (47,55.98) (48,54.14) (49,55.32) (50,55.20) (51,56.73) (52,56.76) (53,55.48) (54,56.88) (55,56.89) (56,59.59) (57,58.37) (58,59.07) (59,59.97) (60,59.84) (61,59.45) (62,60.36) (63,61.75) (64,62.79) (65,62.07) (66,64.54) (67,63.26) (68,64.14) (69,64.83) (70,66.07) (71,66.11) (72,66.11) (73,67.55) (74,68.23) (75,67.90) (76,69.44) (77,69.58) (78,70.70) (79,70.88) (80,71.35) (81,72.26) (82,71.78) (83,72.51) (84,73.14) (85,73.54) (86,73.78) (87,73.98) (88,74.10) (89,74.11) (90,74.14)
};
\addlegendentry{top-1}
\addplot[orange, mark=*, mark options={fill=orange}] coordinates {
(1,28.59) (2,45.69) (3,58.45) (4,56.01) (5,62.14) (6,63.78) (7,69.07) (8,66.01) (9,67.54) (10,68.96) (11,68.40) (12,72.87) (13,71.79) (14,71.89) (15,71.97) (16,71.14) (17,68.49) (18,69.88) (19,72.02) (20,70.07) (21,72.68) (22,67.84) (23,73.50) (24,70.90) (25,73.28) (26,70.98) (27,74.53) (28,72.04) (29,73.37) (30,75.68) (31,74.95) (32,74.24) (33,76.53) (34,74.51) (35,75.56) (36,77.06) (37,74.52) (38,75.97) (39,77.78) (40,75.84) (41,77.79) (42,76.82) (43,76.60) (44,76.37) (45,75.74) (46,79.64) (47,80.31) (48,78.94) (49,80.04) (50,79.88) (51,80.87) (52,81.09) (53,80.22) (54,80.97) (55,81.10) (56,83.05) (57,82.20) (58,82.72) (59,83.06) (60,83.34) (61,82.76) (62,83.69) (63,84.64) (64,85.06) (65,84.79) (66,86.19) (67,85.64) (68,86.10) (69,86.66) (70,87.10) (71,87.51) (72,87.45) (73,88.29) (74,88.80) (75,88.46) (76,89.42) (77,89.46) (78,90.03) (79,90.18) (80,90.42) (81,90.94) (82,90.72) (83,91.03) (84,91.25) (85,91.55) (86,91.66) (87,91.74) (88,91.78) (89,91.84) (90,91.82)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}

The run, epoch by epoch. The kink near epoch 5 is the end of warmup; the steady late climb is the cosine anneal.

The verified path (PJRT)

The verified trainer is apps/imagenette/MainResnet34Imagenet.lean, built as lake build resnet34-imagenet-verified:

-- 1. Same backbone as the Imagenette net, 1000 classes instead of 10.
def resnet34ImagenetVerified : VerifiedNetSpec where
  name     := "ResNet-34 (ImageNet-1k)"
  slug     := "resnet34in"
  inC      := 3
  imageH   := 224
  imageW   := 224
  data     := .imagenet
  shimScript := "generated_resnet34_imagenet_shim.py"
  layers   := [
    .convBnNB 3 64 7 2,          -- 7x7-s2 stem -> BN -> relu    224->112
    .maxPool 3 2,                -- He et al. 3x3-s2, OVERLAPPING 112->56
    .residualStage  64  64 3 1,  -- stage1: 3 identity            @56
    .residualStage  64 128 4 2,  -- stage2: downsample + 3        56->28
    .residualStage 128 256 6 2,  -- stage3: downsample + 5        28->14
    .residualStage 256 512 3 2,  -- stage4: downsample + 2        14->7
    .globalAvgPool,
    .dense 512 1000 ]
  blurb := "ResNet-34 on full 1000-class ImageNet via the VERIFIED renderer ->
            %LOWERER% -> GPU, with the tfds batch shim supplying the same
            augmentation the Lean->JAX reference trainer uses"
  runningBN := true    -- 36 BatchNorms, read off the layer list

-- The head is the ONLY thing that may differ from the 10-class net.
-- Anything else moving means the spec drifted from its Imagenette twin.
#guard resnet34ImagenetVerified.toSpecs.size == resnet34Verified.toSpecs.size
#guard resnet34ImagenetVerified.toSpecs.pop.pop == resnet34Verified.toSpecs.pop.pop
#guard resnet34ImagenetVerified.toSpecs.back!  == (#[1000], 2)

-- 2. 90 epochs at batch 256.
def resnet34ImagenetConfig : VerifiedConfig where
  epochs    := 90
  batchSize := 256

-- 3. The entry point. Heavy-ball momentum, cosine with a 5-epoch warmup
--    at peak 0.1, weight decay 1e-4, label smoothing 0.1.
-- ⚠ LEAN_MLIR_EPOCHS *sets* the schedule; MAX_EPOCHS only ever CAPS it, and
--    `totalSteps := cfg.epochs * nb / accK` is what the cosine anneals over,
--    so capping at 90 would keep annealing over the default.
def runResnet34Imagenet (argv : List String) : IO Unit := do
  let variant := (← IO.getEnv "LEAN_MLIR_VARIANT").getD "mom256"
  let bs      := ((← IO.getEnv "LEAN_MLIR_BATCH").bind (·.toNat?)).getD
                   resnet34ImagenetConfig.batchSize
  let epochs  := ((← IO.getEnv "LEAN_MLIR_EPOCHS").bind (·.toNat?)).getD
                   resnet34ImagenetConfig.epochs
  let baseLR := match (← IO.getEnv "LEAN_MLIR_BASE_LR_U").bind (·.toNat?) with
    | some u => u.toFloat * 1e-6
    | none   => 0.1
  resnet34ImagenetVerified.toNet.trainAdamSched
    { resnet34ImagenetConfig with batchSize := bs, epochs := epochs }
    (argv.head?.getD "data") baseLR 0.9 0.999 5 variant

Three things in it are worth pulling out, because they are what makes the listing a claim rather than a description.

The slug is the artifact. resnet34in names verified_mlir/resnet34in_<variant>_train_step.mlir — momdp64bf16 for the run below — which is pretty(provenGraph) off the same renderer every earlier chapter used. Scaling to ImageNet needed nothing new in it. nClasses, the batch, the optimizer and the slug are all ordinary parameters, so the ImageNet artifacts are three #evals of machinery that was already proved. What had to be built was the data path, not the compiler.

The #guards pin it to the Imagenette net. The 1000-class spec must be the 10-class one with a different head, and the three guards fail the build if any other parameter shape moves. That is what licenses reading the Imagenette chapter’s theorems as being about this network’s body.

The shimScript is per-net and decides the data. It names this net’s own generated augmentation shim — the same shim the reference consumes, which is what makes the two a matched pair rather than two separate stories.

It runs with:

lake run r34-default-bf16-4gpu

The job config behind that name, scripts/jobs/r34-default-bf16-4gpu.conf, carries the device list, both replica knobs, the variant, the per-replica batch and the feed setting; §3 says what the supervisor behind it does.

GPU

Precision

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 3060 (CUDA)

bf16

\(\sim \)15.8 min

90

\(\sim \)23.7 hr

\(\mathbf{74.17\% }\)

\(\mathbf{91.89\% }\)

Global batch 256 as \(4 \times 64\) replicas, bf16 convolutions under an fp32 classifier head, BatchNorm statistics all-reduced across the four replicas, scored over the same \(50{,}000\) images.

The lowerer underneath is interchangeable. Run from the same initialization, the IREE and XLA lowerings of this artifact scored \(17{,}312\) and \(17{,}313\) correct at epoch 5 — one image apart. The proof-carrying tier reaches this render as a step: Theorems 62 and 68 are stated at either precision, and read over \(\mathbb {R}\) the bf16 text’s gradient nodes are the certified ones. What they do not reach is the rounding: that the hardware rounds where the text says and nowhere else is trusted, as it is at every op of an f32 render.

The two paths, side by side

 

reference (JAX)

verified path (PJRT)

lowering

JAX \(\to \) XLA

pretty(provenGraph) \(\to \) XLA

Python in the step

yes

none

evaluation forward

bf16

fp32

precision

bf16 convolutions

bf16 convolutions

classifier head

fp32

fp32

global batch

256 (\(4\times 64\))

256 (\(4\times 64\))

BN statistic group

256 (global)

256 (global)

steps/epoch

5,004

5,004

optimizer

SGD, momentum \(0.9\)

SGD, momentum \(0.9\)

schedule

cosine, 5-epoch warmup

cosine, 5-epoch warmup

val top-1

\(\mathbf{74.14\% }\)

\(\mathbf{74.17\% }\)

val top-5

\(\mathbf{91.83\% }\)

\(\mathbf{91.89\% }\)

Both columns are ResNet-34 on the 2018 recipe, 90 epochs, scored over the same \(50{,}000\) validation images under the same protocol. Rows above the first rule differ; rows between the rules are held equal by construction.

The two paths agree to \(0.03\) points on top-1 and \(0.06\) on top-5, and that is well inside what one run can resolve. At \(n = 50{,}000\) the 95% Wilson interval is \(\pm 0.38\) points on either top-1 — \([73.75, 74.52]\) for the reference, \([73.78, 74.55]\) for the verified path — so the gap in the table is a fraction of the interval, and at this scale a gap under about \(0.4\) points is not yet a result in either direction. That is the floor every side-by-side table in this book is read against. Paired on the same images the reading is the same: the two checkpoints disagree on \(5{,}398\) of them, \(2{,}692\) one way and \(2{,}706\) the other, and McNemar’s exact test puts \(p = 0.86\) on the \(14\) images between them.

Both paths normalize over the global \(256\): the reference’s @jit mesh reduces the batch statistics across its shards, and each of the verified render’s 36 BatchNorms all-reduces its mean and variance across the four replicas — the data-parallel step Theorem 68 ties to one device’s step at \(256\).

The curves converge; they do not merely end together. The middle of the schedule is noisy on both paths — at peak learning rate SGD moves top-1 by several points between consecutive epochs, in both directions, six at the worst on either path — so the row is read for its ends. Over the last fifteen epochs the two average within \(0.01\) of each other on top-1 and \(0.02\) on top-5:

epoch

5

15

30

45

60

75

90

reference (JAX)

35.50

45.32

49.68

50.21

59.84

67.90

74.14

verified path (PJRT)

32.04

42.76

49.09

53.92

58.11

68.57

74.17

\(\Delta \)

\(-3.46\)

\(-2.56\)

\(-0.59\)

\(+3.71\)

\(-1.73\)

\(+0.67\)

\(+0.03\)

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation top-1 (\%)},
    xmin=0, xmax=91, ymin=0, ymax=80,
    xtick={0,15,30,45,60,75,90},
    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=none},
]
\addplot[blue] coordinates {
(1,11.63) (2,22.29) (3,32.31) (4,30.19) (5,35.50) (6,37.22) (7,41.93) (8,39.35) (9,40.69) (10,41.59) (11,42.01) (12,46.38) (13,44.95) (14,45.73) (15,45.32) (16,44.48) (17,41.96) (18,43.56) (19,46.13) (20,43.86) (21,45.83) (22,41.70) (23,47.66) (24,44.46) (25,46.88) (26,45.37) (27,48.26) (28,46.01) (29,47.41) (30,49.68) (31,49.06) (32,48.03) (33,50.76) (34,48.16) (35,49.75) (36,51.55) (37,48.26) (38,50.97) (39,52.25) (40,50.67) (41,52.39) (42,51.48) (43,51.58) (44,50.70) (45,50.21) (46,54.93) (47,55.98) (48,54.14) (49,55.32) (50,55.20) (51,56.73) (52,56.76) (53,55.48) (54,56.88) (55,56.89) (56,59.59) (57,58.37) (58,59.07) (59,59.97) (60,59.84) (61,59.45) (62,60.36) (63,61.75) (64,62.79) (65,62.07) (66,64.54) (67,63.26) (68,64.14) (69,64.83) (70,66.07) (71,66.11) (72,66.11) (73,67.55) (74,68.23) (75,67.90) (76,69.44) (77,69.58) (78,70.70) (79,70.88) (80,71.35) (81,72.26) (82,71.78) (83,72.51) (84,73.14) (85,73.54) (86,73.78) (87,73.98) (88,74.10) (89,74.11) (90,74.14)
};
\addlegendentry{reference (JAX)}
\addplot[red, dashed] coordinates {
(1,9.41) (2,18.96) (3,27.78) (4,33.25) (5,32.04) (6,33.52) (7,37.74) (8,41.38) (9,39.87) (10,45.31) (11,43.41) (12,43.03) (13,44.17) (14,41.47) (15,42.76) (16,44.51) (17,41.08) (18,46.10) (19,43.69) (20,43.36) (21,41.06) (22,42.83) (23,46.98) (24,48.05) (25,43.39) (26,48.80) (27,47.97) (28,48.87) (29,48.43) (30,49.09) (31,49.21) (32,47.68) (33,51.10) (34,47.35) (35,49.50) (36,49.21) (37,51.51) (38,48.92) (39,50.29) (40,47.71) (41,53.44) (42,50.69) (43,51.08) (44,52.56) (45,53.92) (46,54.38) (47,54.00) (48,54.24) (49,57.21) (50,54.38) (51,55.81) (52,53.81) (53,56.65) (54,57.94) (55,56.15) (56,58.07) (57,58.79) (58,56.90) (59,57.68) (60,58.11) (61,60.54) (62,60.61) (63,61.98) (64,62.09) (65,60.61) (66,60.95) (67,63.19) (68,64.03) (69,65.38) (70,65.86) (71,66.75) (72,66.91) (73,66.59) (74,67.14) (75,68.57) (76,68.82) (77,69.54) (78,70.21) (79,71.04) (80,71.40) (81,71.96) (82,72.36) (83,72.98) (84,73.24) (85,73.43) (86,73.94) (87,74.08) (88,74.15) (89,74.14) (90,74.17)
};
\addlegendentry{verified path (PJRT)}
\end{axis}
\end{tikzpicture}

One recipe, two paths to XLA, scored over all \(50{,}000\) validation images at every epoch.

A reasonable stopping point

For the fastest path through this book, the route is roughly:

  • The intro chapters & Ch 1: framework setup (mandatory context).

  • Ch 2–3: MLP and CNN worked examples, the simplest cases of the framework reaching real architectures.

  • Skim Ch 4 (BatchNorm). It’s structurally the hardest chapter and the prose says so up front. The math is right (pdiv_bnNormalize’s three-term cancellation is formalized and matches the codegen), but it’s not the place to spend time if you’re not specifically interested in BN. Spend time running as many MNIST and CIFAR demos as possible.

  • Read this chapter (Ch 5, ResNet-34). Residual blocks are where the chain rule shows it can carry composition through arbitrarily deep architectures, and the proof reduces to one additive-fan-in lemma you’ve already seen. Run the ResNet-50 RSB-A3 ImageNet training demo (§5.8; about a day on four cards).

  • Stop here. Assume Chapters 6–9 (MobileNet, EfficientNet, ConvNeXt, ViT) and the Bestiary (Ch 10) are correct, because they all reuse the same framework with new operators slotting into the same proof tree. Appendix C walks through how that claim is auditable if you ever want to spot-check it.

Reading through Ch 5 is enough to know the framework works. Read the AlphaGo Zero paper (bestiary entry), which combines reinforcement learning with a large residual network of convolutions.

5.8 ResNet-50 on ImageNet: the 2018 recipe and RSB-A3

The basic block ResNet-34 is built from generalizes to the bottleneck block of ResNet-50 — a \(1\times 1\) down, a \(3\times 3\), a \(1\times 1\) back up — at 25.6M parameters against 21.8M. This section moves one lever at a time. First the architecture, with the recipe held fixed: ResNet-50 on the 2018 recipe of §5.7, unchanged, which is worth \(2.7\) points over ResNet-34 on the reference. Then the recipe, with the architecture held fixed: the same network on RSB-A3 (Wightman et al. 2021, “ResNet Strikes Back”), worth about another point. Both recipes run on both paths, so the result is a grid rather than a pair, and the table that closes the section is the 2018 column every later chapter’s recipe is diffed against.

The reference (JAX)

The reference is jax/MainResnet50Imagenet.lean, the bottleneck backbone on the same tfds stream, with the recipe as an argument. The 2018 row is the ResNet-34 recipe with two settings of its own, which A3 keeps: the last BatchNorm \(\gamma \) of each bottleneck starts at zero, and the BatchNorm running statistics decay at \(0.9\) against ResNet-34’s \(0.99\). A3 is LAMB at effective batch 2048 (accumulated as \(4\times 512\)), BCE over Mixup/CutMix targets binarized at timm’s \(0.2\) threshold, RandAugment, and train@160 / eval@224.

GPU

Recipe

BN group

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 3060 (bf16)

2018

256 (global)

\(\sim \)17.2 min

90

\(\sim \)25.8 hr

\(\mathbf{76.76\% }\)

\(\mathbf{93.39\% }\)

4\(\times \) 3060 (bf16)

RSB-A3

512 (Ghost-BN)

\(\sim \)8.9 min

100

\(\sim \)14.8 hr

\(\mathbf{77.75\% }\)

\(\mathbf{93.65\% }\)

Both rows are the final checkpoints scored over all \(50{,}000\) validation images through an fp32 forward, as the verified path and timm score; the trainers’ own bf16 evaluation lands within \(0.04\) of them. A3 normalizes per \(512\)-image micro-batch because it accumulates \(4 \times 512\) to reach \(2048\); the 2018 row is a true global batch of \(256\). That is ghost batch norm (Hoffer, Hubara & Soudry 2017, arXiv:1705.08741): the statistics come from a fixed sub-batch rather than the whole batch, here as a side effect of accumulation rather than a choice, and the A3 render below reproduces it by accumulating the same way. The architecture lever: against ResNet-34’s \(74.14\% \) on the same recipe, the bottleneck is worth \(+2.62\) points. The recipe lever: A3 adds \(+0.99\) on the same network, in less wall clock, because it trains at \(160^2\). It lands \(0.35\) under the paper’s \(78.1\% \) and \(0.30\) under the \(78.05\% \) that timm’s own resnet50.a3_in1k weights score under this protocol, both within one run’s \(\pm 0.36\); what still separates this A3 from timm’s is the marker after the side-by-side table.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation top-1 (\%)},
    xmin=0, xmax=101, ymin=0, ymax=80,
    xtick={0,20,40,60,80,100},
    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=none},
    title style={font=\small},
    title={ResNet-50, two recipes --- reference},
]
\addplot[blue] coordinates {
(1,4.61) (2,13.55) (3,23.27) (4,31.86) (5,37.17) (6,42.05) (7,43.64) (8,45.62) (9,47.41) (10,49.99) (11,48.20) (12,49.40) (13,50.25) (14,50.13) (15,50.84) (16,52.11) (17,53.58) (18,52.44) (19,51.23) (20,54.27) (21,53.22) (22,50.67) (23,54.55) (24,51.11) (25,54.69) (26,56.29) (27,56.59) (28,55.47) (29,55.79) (30,54.37) (31,55.65) (32,56.67) (33,57.43) (34,56.82) (35,58.97) (36,57.52) (37,58.49) (38,58.12) (39,59.16) (40,58.54) (41,59.23) (42,60.58) (43,60.76) (44,60.01) (45,60.73) (46,59.74) (47,62.29) (48,61.71) (49,61.24) (50,63.09) (51,63.18) (52,63.29) (53,63.20) (54,63.51) (55,64.90) (56,63.94) (57,64.46) (58,65.40) (59,66.03) (60,66.63) (61,67.00) (62,67.01) (63,65.71) (64,67.19) (65,68.52) (66,68.01) (67,68.98) (68,69.88) (69,70.09) (70,70.67) (71,70.60) (72,71.68) (73,72.11) (74,72.59) (75,72.87) (76,73.43) (77,73.88) (78,74.37) (79,74.41) (80,74.75) (81,75.31) (82,75.59) (83,75.95) (84,76.16) (85,76.49) (86,76.47) (87,76.81) (88,76.72) (89,76.78) (90,76.80)
};
\addlegendentry{2018 (90 ep)}
\addplot[orange] coordinates {
(1,1.73) (2,5.09) (3,10.22) (4,12.39) (5,14.59) (6,25.05) (7,29.31) (8,30.05) (9,32.80) (10,35.82) (11,33.60) (12,38.26) (13,36.09) (14,43.75) (15,35.76) (16,40.58) (17,44.13) (18,45.98) (19,45.28) (20,50.03) (21,41.44) (22,36.38) (23,44.75) (24,45.85) (25,49.10) (26,51.16) (27,51.70) (28,50.22) (29,50.80) (30,51.67) (31,52.15) (32,50.54) (33,47.02) (34,57.98) (35,49.62) (36,57.81) (37,55.62) (38,55.93) (39,54.04) (40,52.33) (41,55.46) (42,58.17) (43,58.20) (44,59.16) (45,57.42) (46,60.72) (47,56.68) (48,55.91) (49,61.15) (50,57.68) (51,62.57) (52,61.13) (53,60.63) (54,65.53) (55,66.57) (56,65.29) (57,63.69) (58,68.20) (59,64.61) (60,67.37) (61,68.77) (62,67.84) (63,65.91) (64,65.45) (65,68.49) (66,69.80) (67,70.32) (68,68.47) (69,68.30) (70,71.91) (71,72.03) (72,70.40) (73,73.18) (74,73.87) (75,73.34) (76,74.52) (77,74.22) (78,74.93) (79,75.03) (80,74.64) (81,75.48) (82,75.30) (83,75.72) (84,76.17) (85,76.67) (86,76.75) (87,76.64) (88,77.14) (89,77.14) (90,77.25) (91,77.41) (92,77.17) (93,77.36) (94,77.58) (95,77.68) (96,77.61) (97,77.80) (98,77.70) (99,77.64) (100,77.74)
};
\addlegendentry{RSB-A3 (100 ep)}
\end{axis}
\end{tikzpicture}

The two recipes on the reference, epoch by epoch. A3 starts far lower — \(1.73\% \) at epoch 1 against \(4.61\% \) — because BCE over mixup targets gives almost no signal until the logits separate; from roughly epoch 35 the two trade the lead, and A3 holds it outright only from epoch 88. At the matched epoch 90 it leads by \(0.45\) (\(77.25\) against \(76.80\)), and its last ten epochs of anneal add the rest. The schedules are different lengths, so at a shared epoch the two cosines are at different points in their decay; the endpoints are what the \(+0.99\) is measured from.

The verified path (PJRT)

The verified trainer is the ResNet-34 one of §5.7 with the bottleneck backbone substituted, and each recipe is its own render: resnet50in_momdp64bf16 for 2018, and for A3 resnet50in160_lambaccdp4x128wxclipbcebf16 — LAMB \(\times \) BCE-with-logits \(\times \) accumulation \(k=4\) at four replicas of \(128\) to the recipe’s \(2048\), train@160 / eval@224. They run with:

lake run r50-2018-bf16-4gpu
lake run r50-a3-wxclip4x128-bf16-4gpu

GPU

Recipe

BN group

Per epoch

Epochs

Total

Val top-1

Val top-5

4\(\times \) 3060 (bf16)

2018

256 (global)

\(\sim \)21.1 min

90

\(\sim \)31.6 hr

\(\mathbf{77.09\% }\)

\(\mathbf{93.36\% }\)

4\(\times \) 3060 (bf16)

RSB-A3

512 (Ghost-BN)

\(\sim \)12.6 min

100

\(\sim \)20.9 hr

\(\mathbf{77.91\% }\)

\(\mathbf{93.71\% }\)

Scored over the same \(50{,}000\) images. Both renders all-reduce every BatchNorm’s mean and variance across the four replicas (Theorem 69), so the 2018 render normalizes over the global \(256\) and the A3 render over \(4 \times 128 = 512\), the ghost batch the reference’s accumulation gives it.

The A3 render carries the recipe as §5.8 tabulates it, including the three places a bare transcription would miss: BN \(\gamma /\beta \) and biases excluded from weight decay, LAMB’s gradient clip at timm’s default (after the all-reduce and the accumulation, at threshold \(kC\), Proofs.clipFactor_accum), and the no_weight_decay group left out of layer adaptation. The optimizer is tied to the reference at the update as well as the gradient, seven variants to \(\sim \! 10^{-7}\). Both paths run \(625\) steps an epoch and leave \(0.08\% \) of it unused, because \(4 \nmid 2{,}502\). One thing differs from the reference: Mixup’s \(\lambda \) is drawn host-side, once per \(512\)-image micro-batch where the reference draws once per accumulated step, so agreement there is distributional rather than per-step.

The two paths, side by side

 

reference (JAX)

verified path (PJRT)

lowering

JAX \(\to \) XLA

pretty(provenGraph) \(\to \) XLA

Python in the step

yes

none

evaluation forward

bf16

fp32

precision

bf16 convolutions

bf16 convolutions

classifier head

fp32

fp32

last-BN \(\gamma \) at init

\(0\)

\(0\)

BN running-stat decay

\(0.9\)

\(0.9\)

schedule

cosine, 5-epoch warmup

cosine, 5-epoch warmup

2018 (90 ep)

effective batch

256

256

accumulation

none

none

BN statistic group

256 (global)

256 (global)

steps/epoch

5,004

5,004

optimizer

SGD, momentum \(0.9\)

SGD, momentum \(0.9\)

train / eval res

224 / 224

224 / 224

val top-1

\(\mathbf{76.76\% }\)

\(\mathbf{77.09\% }\)

val top-5

\(\mathbf{93.39\% }\)

\(\mathbf{93.36\% }\)

RSB-A3 (100 ep)

effective batch

2048

2048

accumulation

\(4 \times 512\)

\(4 \times 512\)

BN statistic group

512 (Ghost-BN)

512 (Ghost-BN)

steps/epoch

625

625

optimizer

LAMB

LAMB

train / eval res

160 / 224

160 / 224

val top-1

\(\mathbf{77.75\% }\)

\(\mathbf{77.91\% }\)

val top-5

\(\mathbf{93.65\% }\)

\(\mathbf{93.71\% }\)

Both columns are ResNet-50, each block on its recipe, scored over the same \(50{,}000\) validation images under the same protocol. Rows above the first rule differ; every other row but the results is held equal by construction. The BatchNorm statistic group is the setting that changes between the blocks, on both paths at once: the 2018 recipe normalizes over the global \(256\), and A3 over the \(512\)-image micro-batch its accumulation hands each step — ghost batch norm, on the reference and the verified path alike. [TODO: A3 is timm’s in all but three places, on both paths: Mixup or CutMix drawn per batch at \(p = 0.5\) (ours alternate by step), the schedule stepped once an epoch from \(10^{-4}\) to a floor of \(10^{-6}\) (ours per step from zero), RandAugment’s fill at the image mean (ours grey \(128\)). The \(0.35\) and \(0.19\) to the paper’s \(78.1\) are inside one run’s interval. All three are in code since 2026-10-11, with an fp32 evaluation forward, and not yet run; one rerun of both halves takes them — r50-a3-jax-4gpu / r50-a3-wxclip4x128-bf16-4gpu, \({\sim }13.5 + 24\) h.]

The two paths agree within \(0.33\) points on top-1 on both recipes, and within \(0.06\) on top-5 — inside the 95% Wilson interval, \(\pm 0.37\) at \(77\% \) on \(50{,}000\) images, at one seed per cell. Paired on the same images, the two recipes read differently. On A3 the two checkpoints disagree on \(4{,}014\) images, \(1{,}966\) one way and \(2{,}048\) the other, and McNemar’s exact test puts \(p = 0.20\) on the \(82\) between them: not separable. On 2018 they disagree on \(4{,}622\), \(2{,}228\) against \(2{,}394\), and the \(166\)-image lead is \(p = 0.015\): the verified checkpoint is ahead on this validation set. With one seed per arm that is a statement about two trained models, not about the two paths — the nine percent of images the two disagree on is what two runs of one recipe produce, whichever is ahead — and whether the verified path’s 2018 run is ahead by construction needs seeds per arm. And the recipe delta replicates: A3 beats 2018 by \(+0.99\) on the reference and \(+0.82\) on the verified path. Agreement at a single configuration can be a coincidence of that configuration; a difference that reproduces across two independent implementations is harder to arrange by accident, and it is what a one-row comparison cannot show. One thing the table does not settle: the A3 column trains at \(160\) and evaluates at \(224\) while 2018 does both at \(224\), so the recipe delta confounds resolution with recipe, by A3’s own design.

The 2018 curves travel together; the A3 curves meet. On 2018 the two paths swing both ways through the schedule, as ResNet-34’s do — the verified path minus the reference averages \(+0.22\), \(-0.52\), \(-0.19\), \(+0.07\) and \(+0.24\) over successive twenty-epoch windows, five points apart at the widest. On A3 the verified path runs about a point ahead through the middle of the schedule — \(+1.92\), \(+0.24\), \(+1.16\) and \(+1.05\) by the same windows, ten points at the widest — and the two converge in the anneal, to \(+0.29\) over the last twenty epochs and \(+0.26\) over the last ten. Where A3’s route difference lives is not established; the one difference between its columns that 2018’s do not share is where the Mixup/CutMix draw happens, once per \(512\)-image micro-batch on the verified path against once per accumulated step on the reference, and it is untested. It is why these pairs are read at their endpoints and nowhere else (the rows are the runs’ own per-epoch evaluation through the trainers’ bf16 forward; the headline numbers rescore the final checkpoints through fp32, within \(0.04\) of the last column):

epoch

5

15

30

45

60

75

90

100

2018 (90 ep)

reference (JAX)

37.17

50.84

54.37

60.73

66.63

72.87

76.80

—

verified path (PJRT)

36.38

50.71

55.14

60.72

65.34

73.21

77.09

—

\(\Delta \)

\(-0.79\)

\(-0.13\)

\(+0.77\)

\(-0.01\)

\(-1.29\)

\(+0.34\)

\(+0.29\)

 

RSB-A3 (100 ep)

reference (JAX)

14.59

35.76

51.67

57.42

67.37

73.34

77.25

77.74

verified path (PJRT)

21.57

44.32

51.53

61.47

67.09

74.61

77.52

77.91

\(\Delta \)

\(+6.98\)

\(+8.56\)

\(-0.14\)

\(+4.05\)

\(-0.28\)

\(+1.27\)

\(+0.27\)

\(+0.17\)

\begin{tikzpicture} 
\begin{axis}[
    width=0.49\linewidth, height=6cm,
    xlabel={Epoch}, ylabel={Validation top-1 (\%)},
    xmin=0, xmax=91, ymin=0, ymax=80,
    xtick={0,15,30,45,60,75,90},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    legend style={font=\scriptsize},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    title style={font=\small},
    title={2018 (90 ep)},
    every axis plot/.append style={line width=1pt, mark=none},
]
\addplot[blue] coordinates {
(1,4.61) (2,13.55) (3,23.27) (4,31.86) (5,37.17) (6,42.05) (7,43.64) (8,45.62) (9,47.41) (10,49.99) (11,48.20) (12,49.40) (13,50.25) (14,50.13) (15,50.84) (16,52.11) (17,53.58) (18,52.44) (19,51.23) (20,54.27) (21,53.22) (22,50.67) (23,54.55) (24,51.11) (25,54.69) (26,56.29) (27,56.59) (28,55.47) (29,55.79) (30,54.37) (31,55.65) (32,56.67) (33,57.43) (34,56.82) (35,58.97) (36,57.52) (37,58.49) (38,58.12) (39,59.16) (40,58.54) (41,59.23) (42,60.58) (43,60.76) (44,60.01) (45,60.73) (46,59.74) (47,62.29) (48,61.71) (49,61.24) (50,63.09) (51,63.18) (52,63.29) (53,63.20) (54,63.51) (55,64.90) (56,63.94) (57,64.46) (58,65.40) (59,66.03) (60,66.63) (61,67.00) (62,67.01) (63,65.71) (64,67.19) (65,68.52) (66,68.01) (67,68.98) (68,69.88) (69,70.09) (70,70.67) (71,70.60) (72,71.68) (73,72.11) (74,72.59) (75,72.87) (76,73.43) (77,73.88) (78,74.37) (79,74.41) (80,74.75) (81,75.31) (82,75.59) (83,75.95) (84,76.16) (85,76.49) (86,76.47) (87,76.81) (88,76.72) (89,76.78) (90,76.80)
};
\addlegendentry{reference (JAX)}
\addplot[red, dashed] coordinates {
(1,5.83) (2,14.17) (3,24.09) (4,31.30) (5,36.38) (6,42.09) (7,44.53) (8,46.57) (9,46.38) (10,51.19) (11,47.59) (12,47.62) (13,51.22) (14,49.94) (15,50.71) (16,54.36) (17,50.95) (18,52.90) (19,53.99) (20,54.20) (21,50.97) (22,50.55) (23,53.13) (24,55.43) (25,52.39) (26,55.46) (27,55.35) (28,55.64) (29,53.79) (30,55.14) (31,56.84) (32,56.34) (33,56.50) (34,57.33) (35,53.79) (36,57.62) (37,57.59) (38,60.20) (39,56.81) (40,58.87) (41,58.40) (42,59.89) (43,58.61) (44,61.13) (45,60.72) (46,62.00) (47,60.59) (48,62.10) (49,61.89) (50,61.96) (51,62.03) (52,61.22) (53,63.24) (54,64.14) (55,64.05) (56,65.10) (57,66.14) (58,65.66) (59,65.97) (60,65.34) (61,66.79) (62,66.74) (63,67.11) (64,67.89) (65,68.24) (66,68.81) (67,69.57) (68,69.26) (69,68.36) (70,70.98) (71,71.16) (72,71.54) (73,72.21) (74,72.01) (75,73.21) (76,73.24) (77,73.80) (78,74.30) (79,74.89) (80,75.13) (81,75.41) (82,75.80) (83,76.20) (84,76.35) (85,76.73) (86,76.75) (87,76.96) (88,77.07) (89,77.09) (90,77.09)
};
\addlegendentry{verified path (PJRT)}
\end{axis}
\end{tikzpicture}
\begin{tikzpicture} 
\begin{axis}[
    width=0.49\linewidth, height=6cm,
    xlabel={Epoch},
    xmin=0, xmax=101, ymin=0, ymax=80,
    xtick={0,20,40,60,80,100},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    legend style={font=\scriptsize},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    title style={font=\small},
    title={RSB-A3 (100 ep)},
    every axis plot/.append style={line width=1pt, mark=none},
]
\addplot[blue] coordinates {
(1,1.73) (2,5.09) (3,10.22) (4,12.39) (5,14.59) (6,25.05) (7,29.31) (8,30.05) (9,32.80) (10,35.82) (11,33.60) (12,38.26) (13,36.09) (14,43.75) (15,35.76) (16,40.58) (17,44.13) (18,45.98) (19,45.28) (20,50.03) (21,41.44) (22,36.38) (23,44.75) (24,45.85) (25,49.10) (26,51.16) (27,51.70) (28,50.22) (29,50.80) (30,51.67) (31,52.15) (32,50.54) (33,47.02) (34,57.98) (35,49.62) (36,57.81) (37,55.62) (38,55.93) (39,54.04) (40,52.33) (41,55.46) (42,58.17) (43,58.20) (44,59.16) (45,57.42) (46,60.72) (47,56.68) (48,55.91) (49,61.15) (50,57.68) (51,62.57) (52,61.13) (53,60.63) (54,65.53) (55,66.57) (56,65.29) (57,63.69) (58,68.20) (59,64.61) (60,67.37) (61,68.77) (62,67.84) (63,65.91) (64,65.45) (65,68.49) (66,69.80) (67,70.32) (68,68.47) (69,68.30) (70,71.91) (71,72.03) (72,70.40) (73,73.18) (74,73.87) (75,73.34) (76,74.52) (77,74.22) (78,74.93) (79,75.03) (80,74.64) (81,75.48) (82,75.30) (83,75.72) (84,76.17) (85,76.67) (86,76.75) (87,76.64) (88,77.14) (89,77.14) (90,77.25) (91,77.41) (92,77.17) (93,77.36) (94,77.58) (95,77.68) (96,77.61) (97,77.80) (98,77.70) (99,77.64) (100,77.74)
};
\addlegendentry{reference (JAX)}
\addplot[red, dashed] coordinates {
(1,1.73) (2,6.80) (3,9.82) (4,14.77) (5,21.57) (6,26.43) (7,27.06) (8,30.68) (9,38.14) (10,38.56) (11,38.22) (12,37.12) (13,42.76) (14,45.39) (15,44.32) (16,46.12) (17,45.22) (18,40.42) (19,47.01) (20,46.75) (21,48.36) (22,46.66) (23,49.37) (24,49.13) (25,47.25) (26,49.78) (27,52.33) (28,51.32) (29,51.50) (30,51.53) (31,51.96) (32,49.48) (33,51.01) (34,53.82) (35,40.69) (36,51.96) (37,53.93) (38,49.24) (39,57.06) (40,54.52) (41,53.82) (42,57.46) (43,56.68) (44,59.59) (45,61.47) (46,60.64) (47,61.64) (48,62.22) (49,62.22) (50,63.28) (51,64.32) (52,63.62) (53,61.96) (54,65.22) (55,65.25) (56,63.24) (57,64.47) (58,66.68) (59,68.47) (60,67.09) (61,67.54) (62,68.65) (63,69.64) (64,69.07) (65,70.86) (66,69.11) (67,71.57) (68,71.90) (69,71.51) (70,72.01) (71,71.60) (72,71.98) (73,73.71) (74,73.65) (75,74.61) (76,74.39) (77,74.09) (78,75.21) (79,75.77) (80,75.63) (81,75.71) (82,75.77) (83,76.54) (84,76.63) (85,76.79) (86,77.23) (87,76.91) (88,77.15) (89,77.27) (90,77.52) (91,77.63) (92,77.80) (93,77.72) (94,77.84) (95,77.85) (96,77.84) (97,77.89) (98,77.91) (99,77.89) (100,77.91)
};
\addlegendentry{verified path (PJRT)}
\end{axis}
\end{tikzpicture}

Both recipes on both paths, scored over all \(50{,}000\) validation images at every epoch.

What A3 changes from the 2018 recipe

The 2018-recipe row above is the original paper’s SGD-with-momentum plus the “bag of tricks” polish (cosine schedule, warmup, label smoothing, random-resized-crop). RSB-A3 is the 2021 “ResNet Strikes Back” recipe (Wightman et al.). Both rows are ResNet-50, which is the point: every knob that A3 sets differently is one lever of the three-year jump in how these networks are trained, and holding the architecture fixed is what stops the comparison confounding the recipe with the backbone. The backbone and the entry point are the same for both, and each recipe has its own spec, shim and render. Here is the full diff:

Knob

ResNet-50 (2018)

RSB-A3 (2021)

What the A3 choice buys

Backbone

bottleneck, 25.6 M

bottleneck, 25.6 M

unchanged, which is what makes the rest of this table a recipe diff

Optimizer

SGD + momentum \(0.9\)

LAMB

layer-wise adaptive LR, designed for very large batches

Effective batch

256

2048 (\(512\times 4\) accum)

the large-batch regime LAMB targets; grad-accum fits it on 16 GB

Peak LR

\(0.1\)

\(0.008\) @ bs2048

LAMB’s trust-ratio scale — not comparable to SGD’s \(0.1\)

Epochs

90

100

essentially the same budget (A3 is the cheap RSB tier)

Loss

softmax cross-entropy

BCE-with-logits, multi-hot, binarized at \(0.2\)

treats classes independently; matches mixed soft targets

Label smoothing

\(0.1\)

\(0.0\)

subsumed — BCE over soft mixup labels already softens targets

Augmentation

RRC + hflip

RRC + hflip

unchanged, and it is the BASE both recipes train on; the two rows below are what A3 layers on top of it

Mixup / CutMix

none

\(\alpha \, 0.1\) / \(\alpha \, 1.0\)

interpolate/paste samples \(\to \) soft multi-hot targets, strong regularizer

RandAugment

none

m6 N2 mstd0.5 inc1

automated heavy color+geometric augmentation policy

Weight decay

\(10^{-4}\), all params

\(0.02\), skip BN \(\gamma /\beta \) + bias

\(200\times \) stronger, but never decays scale/shift/bias params

Train / eval res

\(224\) / \(224\)

\(160\) / \(224\)

FixRes: train at cheap low res (\(\sim \)2\(\times \) faster/step), test at high res — the train/test gap helps

BN statistics

global over batch

Ghost-BN over 512

side-effect of grad-accum (each micro-step normalizes its own 512); a single big-memory card would remove it

What carries over unchanged: cosine LR decay, the 5-epoch warmup, the base augmentation in the row above, zero-\(\gamma \) initialization, bf16 convolution under an fp32 classifier head, and running-BN evaluation. The through-line of the 2018\(\to \)2021 shift is “regularize harder, train bigger”: heavier augmentation (RandAugment + Mixup + CutMix), a loss that accepts soft targets (BCE), an order-of-magnitude more weight decay applied selectively, and a large-batch optimizer (LAMB) to make the longer, noisier training converge, with FixRes buying back the compute the extra epochs would cost. On the reference every one of these is a one-line TrainConfig change; on the verified path each is a render variant or a shim setting on the same backbone, and the \(160\) train resolution is a spec of its own.

5.9 Side quest: the A2 and A1 recipes

A3 is the cheap tier of “ResNet Strikes Back”. The other two — A2 at 300 epochs and A1 at 600, both at \(224^2\) — render on the verified path as a2 and a1 (the reference’s a2-accum and a1), and neither has been run:

tier

epochs

train res

verified render

top-1

A3 (rsb-faithful)

100

\(160\)

shipped, and run (§5.8)

\(\mathbf{77.91\% }\)

A2 (a2)

300

\(224\)

rendered, sd \(0.05\), no EMA

\(79.8\% \) (paper)

A1 (a1)

600

\(224\)

rendered, sd \(0.05\), no EMA

\(80.4\% \) (paper)

Four artifacts: each tier at four replicas of \(128\), in both precisions — under synchronised BatchNorm the recipe’s ghost batch of \(512\) per micro-step, as for A3. A1 is its own render rather than A2’s with a flag: the weight decay is baked into the artifact, \(0.01\) against A2’s \(0.02\).

A full schedule, four cards, each path’s own trainer timed on real ImageNet at the job’s shape — four replicas of \(128\) accumulated four times to \(2048\), \(625\) optimizer steps an epoch:

   

reference (JAX)

verified path (PJRT)

4 cards, bf16

epochs

ms/step

total

ms/step

total

A2

\(300\)

\(1{,}467\)

\(76\) h  (\(3.2\) d)

\(1{,}784\)

\(93\) h  (\(3.9\) d)

A1

\(600\)

\(1{,}469\)

\(153\) h  (\(6.4\) d)

\(1{,}776\)

\(185\) h  (\(7.7\) d)

A step is one optimizer step, four micro-batches. Totals are training steps alone; evaluation adds a few hours a run.

What the two renders carry, and where they stop short of the paper:

In

Still out

BCE target threshold \(0.2\) (timm’s –bce-target-thresh), applied in each recipe’s own shim after mixing; A1 adds label smoothing \(0.1\)

Model EMA — RSB Table 2 runs none; the emalamb twins stay on disk unused

Stochastic depth \(0.05\) — sixteen sites, one per bottleneck, on the residual branch

Repeated augmentation is stream-level, not per-batch

Repeated augmentation \(3\times \); both tiers, four replicas

Mixup and CutMix alternate step by step, where timm draws which one per batch

Bicubic RandAugment geometry, matched to PIL’s as timm resolves it, over torchvision’s random-resized crop

RandAugment’s geometric ops fill with grey \(128\), where timm fills with the image mean

A1’s own weight decay (\(0.01\)) and own shim (Mixup \(\alpha \, 0.2\), label smoothing \(0.1\))

The learning rate is set per step, where timm’s scheduler steps once per epoch

As A3: wdExcludeNormBias, LAMB’s clip at \(kC\) after the all-reduce and the accumulation, BCE-with-logits over Mixup/CutMix soft labels, accumulation to \(2048\) at \(k=4\)

Neither tier has been run

Also as A3: zero-\(\gamma \) bottlenecks, BN decay \(0.9\), the fp32 classifier head, evaluation at timm’s \(0.95\) crop

 

Optimizer tied at the update, seven variants to \({\sim }10^{-7}\)

 

[TODO: as A3’s three, plus timm’s index-level RASampler for the \(3\times \) repeats; neither tier has run — r50-a2-bf16-4gpu / r50-a1-bf16-4gpu, r50-a2accum-jax-4gpu / r50-a1-jax-4gpu.]