Verified Deep Learning with Lean 4

4 CIFAR with BatchNorm

This chapter is the bridge between the MNIST chapters and the deep-network chapters that follow. Chapter 3 did MNIST with a two-convolution net and a 2\(\times \)512 dense head. Chapter 5 (ResNet-34) goes thirty-four layers deep. The network here sits exactly in between. It keeps MNIST’s same 2\(\times \)512 head and the same conv/ReLU/max-pool machinery, but stacks the convolutions four stages deep (eight in all) on a harder dataset (CIFAR-10). That is the first point in the book where depth is enough to make two things bite: BatchNorm starts to earn its keep, and the choice of optimizer starts to matter. The depth itself is what ResNet then scales to thirty-four layers. So the chapter has two jobs: prove the one new operator (BatchNorm), and use the deeper net to measure what actually governs training (§4.3).

Proving BN is also what makes this structurally the hardest chapter in the book. BN’s inverse-stddev term \(1/\sqrt{\sigma ^2 + \varepsilon }\) has a gradient that blows up as \(\sigma ^2 \to 0\). Proving the backward pass exists and is bounded requires ContinuousLinearMap-based real-analysis machinery from Mathlib that none of the previous chapters needed. If you’re new to formal math, skim the proofs and trust them. The takeaways are concrete:

  • BN’s gradient has a closed-form 3-term formula (Theorem 48).

  • The formula needs \(\varepsilon {\gt} 0\) to stay bounded (Theorem 46).

  • BN’s payoff is speed and accuracy: a deeper network reaches a given accuracy in far fewer epochs with it than without, and finishes three to eight points higher depending on the optimizer. Whether five runs of one command land close together is decided by something else, the group the statistics are taken over, and the example in §4.3 measures all three.

The proofs themselves use Mathlib’s HasFDerivAt.sqrt, (hasDerivAt_inv).comp_hasFDerivAt, and a centering CLM chained through the chain rule from Ch 1. They’re correct (the Lean kernel checks them) and they’re available in Proofs/Architectures/BatchNorm.lean for the curious. You do not need to understand them line-by-line to use BN as a layer or to follow the rest of the book. Ch 5 (ResNet-34) is the easiest chapter in the book and follows immediately. This is a localized difficulty spike, not the new normal.

What BN actually is

BatchNorm (Ioffe & Szegedy, 2015) takes a batch of activations, \(x\), and does three things in sequence. Each is one line of code. Together they are the layer.

  1. Center. Compute the batch mean \(\mu = \frac{1}{n} \sum _k x_k\) and subtract it from every sample: \(x - \mu \). Output has mean zero.

  2. Normalize. Compute the batch variance \(\sigma ^2\), add a small \(\varepsilon \) for numerical safety, and divide: \(\hat{x} = (x - \mu )/\sqrt{\sigma ^2 + \varepsilon }\). Output has unit variance.

  3. Affine. Scale and shift with learnable per-channel parameters \(\gamma \) and \(\beta \): \(\mathrm{bn}(x) = \gamma \hat{x} + \beta \).

One thing to fix before the math, because the chapter’s measurements depend on it. Ioffe & Szegedy take \(\mu \) and \(\sigma ^2\) over the batch: every image in the mini-batch, at every spatial position, for one channel, \(N \times H \times W\) cells per statistic. That is the layer this chapter trains, with running statistics kept for evaluation, as ResNet-34 and every BatchNorm net after it do. The chapter also trains a second form of the same layer, per image: for each example and each channel, over that image’s \(32 \times 32 = 1{,}024\) spatial cells, with the batch axis never reduced. Nothing crosses from one example to another in that form, so it has no running statistics, computes the same thing in training and at evaluation, and keeps every op in the emitted graph per-example, which is what lets the step theorems below be stated one example at a time. The three steps and their Jacobians are the same for any group of values that is normalized together; \(n\) below is the group size, and the batched form, bnBatchTensor4, is Theorem 51. Where this chapter says “BatchNorm” without qualification it means the batch form; the per-image form is named each time it appears.

The chapter’s first six theorems are the Jacobian of each step and the VJPs that fall out of them. Theorem 50 closes the loop by composing them. The forward is three steps, so the chain rule (Theorem 2) gives us three Jacobians to multiply, and the “BN three-term backward” is exactly that product written out.

4.1 Run it first

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

lake exe cache get                            # Mathlib oleans, ~30 s
./scripts/datasets/download_cifar.sh          # ~170 MB
lake build cifar8wb-bbn-ablation
./.lake/build/bin/cifar8wb-bbn-ablation data

That one binary runs the same network six times: three optimizers, each in fp32 and again in bf16. Here is the first of the six, plain SGD in fp32, on one RTX 4060 Ti (CUDA 12.9). The log is runs/2026-10-24-cifar8wb-bbn-xla-cuda/cifar8wb-bbn.log, with XLA’s startup banner removed, the per-step loss lines dropped, and epochs 6 through 36 elided:

[pjrt_ffi] XLA backend: PJRT 0.114, 1 device(s)
[pjrt_ffi] command buffers: enabled (CUDA default)
[pjrt_ffi] compiled verified_mlir/cifar8wb_bbn_sgd_train_step.mlir
             (@cifar8wb_bbn_sgd_train_step, 133 outputs, 1 replica) in 2620 ms
[pjrt_ffi] compiled verified_mlir/cifar8wb_bbn_fwd.mlir
             (@cifar8wb_bbn_fwd, 1 outputs, 1 replica) in 781 ms
[pjrt_ffi] compiled verified_mlir/cifar8wb_bbn_fwd_eval.mlir
             (@cifar8wb_bbn_fwd_eval, 1 outputs, 1 replica) in 198 ms
════════ cifar8wb-bbn (batch-statistics BN) f32 — SGD (lr 0.1) ════════
Wide-head CIFAR-10 CNN + per-channel BatchNorm at BATCH statistics
  (8 convs, BATCHED op family, d1=512, running-stat eval)
  via the VERIFIED renderer → XLA/PJRT → GPU
  xla/pjrt verified_mlir/cifar8wb_bbn_sgd_train_step.mlir
  xla/pjrt verified_mlir/cifar8wb_bbn_fwd.mlir
  xla/pjrt verified_mlir/cifar8wb_bbn_fwd_eval.mlir
  train 50000, test 10000; bs 128, CIFAR-CNN8-wide-BN-batchstat sgd
    (constant lr 0.100000), He init
Epoch 1/40: loss=1.497842 lr=0.100000
  epoch 1: test_acc = 4703/10000 = 47.030000%  top5 = 9113/10000 = 91.130000%
             [95% CI 46.05–48.01]
Epoch 2/40: loss=1.106227 lr=0.100000
  epoch 2: test_acc = 5861/10000 = 58.610000%  top5 = 9601/10000 = 96.010000%
             [95% CI 57.64–59.57]
Epoch 3/40: loss=0.957487 lr=0.100000
  epoch 3: test_acc = 6366/10000 = 63.660000%  top5 = 9637/10000 = 96.370000%
             [95% CI 62.71–64.60]
Epoch 4/40: loss=0.861241 lr=0.100000
  epoch 4: test_acc = 6334/10000 = 63.340000%  top5 = 9737/10000 = 97.370000%
             [95% CI 62.39–64.28]
Epoch 5/40: loss=0.794502 lr=0.100000
  epoch 5: test_acc = 5424/10000 = 54.240000%  top5 = 9053/10000 = 90.530000%
             [95% CI 53.26–55.21]
Epoch 37/40: loss=0.320061 lr=0.100000
  epoch 37: test_acc = 7603/10000 = 76.030000%  top5 = 9823/10000 = 98.230000%
             [95% CI 75.18–76.86]
Epoch 38/40: loss=0.310950 lr=0.100000
  epoch 38: test_acc = 7820/10000 = 78.200000%  top5 = 9860/10000 = 98.600000%
             [95% CI 77.38–79.00]
Epoch 39/40: loss=0.305377 lr=0.100000
  epoch 39: test_acc = 7784/10000 = 77.840000%  top5 = 9862/10000 = 98.620000%
             [95% CI 77.02–78.64]
Epoch 40/40: loss=0.298878 lr=0.100000
  epoch 40: test_acc = 7741/10000 = 77.410000%  top5 = 9847/10000 = 98.470000%
             [95% CI 76.58–78.22]
done (trained CIFAR-CNN8-wide-BN-batchstat sgd + constant lr via packed threading).

Forty epochs in about a minute, and 77.41% on the full 10,000-image CIFAR-10 test set, 98.47% inside the top five. CIFAR-10 is a much harder problem than MNIST, so this is the first chapter where the headline accuracy drops out of the nineties. BatchNorm’s backward is also the first in this book you can’t read off by inspection.

The test accuracy is not monotone: it can fall several points from one epoch to the next while the training loss keeps falling. That is the evaluation rather than the training: the test forward runs on running statistics that lag the weights at this learning rate. §4.3 comes back to it, because it is where the chapter’s spreads come from.

The learning rate is constant here — no warmup, no decay. Run five times at this setting the net lands between \(74.9\) and \(77.9\% \), so the number above is an ordinary draw rather than a best one, and the tables later in the chapter report medians over five for the same reason.

§4.3 runs this same binary, its per-image sibling and the no-BN peer across all three optimizers, which is where the chapter’s measurements come from, and lake run cifar is those three binaries.

The centering term has an indirect path

When you jiggle a single input \(x_i\), you do not only jiggle \(x_i\). You also jiggle the batch mean \(\mu \), and \(\mu \) appears in every sample’s centered value \(x_k - \mu \). So jiggling \(x_i\) by \(\varepsilon \) shifts every centered value by \(-\varepsilon /n\) plus the direct \(\varepsilon \) on the \(i\)th sample.

\[ \frac{\partial (x_j - \mu )}{\partial x_i} \; =\; \delta _{ij} - \frac{1}{n}. \]

The \(\delta _{ij}\) is the direct effect. The \(-1/n\) is the indirect effect through \(\mu \). Theorem 45 states this formally. Forgetting the \(-1/n\) is the most common hand-derivation mistake on BN, and is exactly what the formal proof prevents.

The inverse-stddev term

The normalize step divides by \(\sqrt{\sigma ^2 + \varepsilon }\), where \(\sigma ^2 = \frac{1}{n} \sum _k (x_k - \mu )^2\) is itself a function of every input. Jiggle \(x_i\) by \(\varepsilon \) and \(\sigma ^2\) changes, which means the divisor changes, which means every output \(\hat{x}_j\) changes, not just \(\hat{x}_i\).

Working through the chain rule (\(x \mapsto x^2 \mapsto \text{mean} \mapsto \sqrt{\cdot + \varepsilon } \mapsto 1/\cdot \)) gives

\[ \frac{\partial }{\partial x_i} \frac{1}{\sqrt{\sigma ^2 + \varepsilon }} \; =\; -\frac{1}{(\sigma ^2 + \varepsilon )^{3/2}} \cdot \frac{x_i - \mu }{n} \; =\; -\, \mathrm{istd}^3 \cdot \frac{x_i - \mu }{n}. \]

That \(\mathrm{istd}^3\) is what makes the BN backward expensive: the gradient of one sample depends on every sample’s centered value, scaled by the cube of the inverse standard deviation. Theorem 47 states this.

Notice what happens to the formula as \(\sigma ^2 \to 0\): \(\mathrm{istd} \to 1/\sqrt{\varepsilon }\), bounded. Without \(\varepsilon \) the gradient diverges. Theorem 46 is the formal statement that \(\varepsilon {\gt} 0\) is sufficient to make this term differentiable. This is the first place in the book where the math requires real-analysis machinery beyond chain-sum-product (ConvNeXt’s GELU and the transformer’s softmax are the others); everything else in the framework reduces to those three rules.

The three-term backward

Compose the three forward steps and apply the product rule on \(\hat{x} = (x - \mu ) \cdot \mathrm{istd}\). The cross-terms collapse (the centered sum \(\sum _k (x_k - \mu ) = 0\) is what saves us) and what falls out is a one-line backward:

\[ dx \; =\; \frac{\mathrm{istd} \cdot \gamma }{n}\, \Bigl(\, n\, dy \; -\; \textstyle \sum _k dy_k \; -\; \hat{x} \cdot \textstyle \sum _k (dy_k \, \hat{x}_k)\, \Bigr). \]

Three terms inside the parentheses, one per forward step’s indirect effect:

  • \(n\, dy\): the direct effect, every sample’s upstream gradient.

  • \(-\sum _k dy_k\): the centering correction, subtracts the total upstream gradient because shifting the mean shifts every sample.

  • \(-\hat{x} \cdot \sum _k (dy_k\, \hat{x}_k)\): the normalization correction, subtracts the projection of the upstream gradient onto \(\hat{x}\), because rescaling by the inverse stddev couples every sample’s gradient through the shared divisor.

Theorem 48 formalizes this. Every production BN implementation (PyTorch, JAX, TensorFlow, custom CUDA kernels) computes this exact expression. The formula has been known since 2015. The value of the formal proof is that the Lean kernel mechanically verifies we are computing the right thing at every training step.

The affine step is dense-with-broadcasting

The third step, \(\gamma \hat{x} + \beta \), is structurally a dense layer applied per-channel. Its Jacobian \(\partial (\gamma v + \beta )/\partial v_i = \gamma \delta _{ij}\) is exactly the dense-Jacobian computation of Theorem 17, just lifted to a tensor shape and broadcast across spatial dimensions. Theorem 44 and Theorem 49 are essentially corollaries of the dense theorems. The new content here is zero.

Putting it together

Theorem 50 is the composition: \(\mathrm{bn} = \mathrm{affine} \circ \mathrm{normalize}\), with VJPs chained via the same \(\mathrm{vjpComp}\) rule (Theorem 11). The 3-term backward is the centerpiece, affine is a corollary, and composition is a one-line proof. The structural story of the chapter is: one new Mathlib-level analytic dependency (sqrt and recip differentiability), one formula, and the rest is the same chain rule we already had.

4.2 The theorems

Figure 4-1 is the dependency graph of this section.

\begin{tikzpicture} [x=1pt,y=1pt, every node/.style={inner sep=1.11pt, font=\fontsize{5.19}{6.23}\selectfont\ttfamily}]
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=44.9pt, minimum height=9.5pt] at (111.4pt,55.3pt) {\hyperref[ax:pdiv_bnAffine]{pdiv\_bnAffine}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=51.2pt, minimum height=9.5pt] at (111.4pt,138.3pt) {\hyperref[ax:pdiv_bnCentered]{pdiv\_bnCentered}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=54.3pt, minimum height=15.9pt, double] at (27.1pt,96.8pt) {\hyperref[ax:bnIstdBroadcast_differentiable]{bnIstdBroadcast\_}\\\hyperref[ax:bnIstdBroadcast_differentiable]{differentiable}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=66.8pt, minimum height=9.5pt] at (111.4pt,96.8pt) {\hyperref[ax:pdiv_bnIstdBroadcast]{pdiv\_bnIstdBroadcast}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=57.4pt, minimum height=9.5pt] at (197.2pt,107.2pt) {\hyperref[thm:bnNormalizeHasVJP]{bnNormalizeHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=48.1pt, minimum height=9.5pt] at (197.2pt,55.3pt) {\hyperref[thm:bnAffineHasVJP]{bnAffineHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=29.4pt, minimum height=9.5pt, double] at (264.3pt,76.0pt) {\hyperref[thm:bnHasVJP]{bnHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=66.8pt, minimum height=9.5pt] at (336.1pt,44.9pt) {\hyperref[thm:bnBatchTensor4HasVJP]{bnBatchTensor4HasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=35.6pt, minimum height=9.5pt] at (111.4pt,282.2pt) {\hyperref[thm:cifar_fold]{cifar\_fold}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=51.2pt, minimum height=9.5pt] at (111.4pt,262.2pt) {\hyperref[thm:cifar_loss_grad]{cifar\_loss\_grad}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=44.9pt, minimum height=9.5pt] at (27.1pt,302.3pt) {\hyperref[thm:cifar_bn_fold]{cifar\_bn\_fold}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=51.2pt, minimum height=9.5pt] at (111.4pt,242.2pt) {\hyperref[thm:cifar8_step_tie]{cifar8\_step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=57.4pt, minimum height=9.5pt] at (111.4pt,302.3pt) {\hyperref[thm:cifar8bn_step_tie]{cifar8bn\_step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=54.3pt, minimum height=9.5pt] at (111.4pt,222.2pt) {\hyperref[thm:cifar8_step_tieG]{cifar8\_step\_tieG}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=60.5pt, minimum height=9.5pt] at (111.4pt,202.1pt) {\hyperref[thm:cifar8bbn_step_tie]{cifar8bbn\_step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=66.8pt, minimum height=9.5pt] at (426.6pt,108.7pt) {\hyperref[thm:cifar8bbn_whole_back]{cifar8bbn\_whole\_back}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=63.6pt, minimum height=9.5pt] at (426.6pt,44.9pt) {\hyperref[thm:cifar8bbn_loss_grad]{cifar8bbn\_loss\_grad}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=54.3pt, minimum height=9.5pt] at (336.1pt,108.7pt) {\hyperref[thm:cifar8_loss_grad]{cifar8\_loss\_grad}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=63.6pt, minimum height=9.5pt] at (111.4pt,323.8pt) {\hyperref[thm:cifar8_sgd_descends]{cifar8\_sgd\_descends}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=3.0pt, align=center, minimum width=48.1pt, minimum height=15.9pt] at (27.1pt,349.0pt) {\hyperref[thm:optimizer_tails_faithful]{optimizer\_}\\\hyperref[thm:optimizer_tails_faithful]{tails\_faithful}};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=41.2pt, minimum height=13.2pt, text=gray!60!black, align=center, font=\fontsize{4.08}{4.90}\selectfont\ttfamily] at (336.1pt,130.2pt) {\hyperref[ax:maxPool2HasVJP3]{maxPool2HasVJP3}\\(ch 3)};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=43.6pt, minimum height=13.2pt, text=gray!60!black, align=center, font=\fontsize{4.08}{4.90}\selectfont\ttfamily] at (27.1pt,323.8pt) {\hyperref[thm:cnn_sgd_descends]{cnn\_sgd\_descends}\\(ch 3)};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=43.6pt, minimum height=13.2pt, text=gray!60!black, align=center, font=\fontsize{4.08}{4.90}\selectfont\ttfamily] at (27.1pt,242.2pt) {17 statements of\\ch 1, 2, 3};
  \draw[->, >=stealth, black!70, line width=0.30pt] (134.1pt,55.3pt) .. controls (143.4pt,55.3pt) and (154.4pt,55.3pt) .. (164.6pt,55.3pt) -- (172.9pt,55.3pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (125.9pt,133.3pt) .. controls (139.1pt,128.4pt) and (159.3pt,120.9pt) .. (174.8pt,115.2pt) -- (182.7pt,112.3pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (54.5pt,96.8pt) .. controls (59.3pt,96.8pt) and (64.3pt,96.8pt) .. (69.3pt,96.8pt) -- (77.7pt,96.8pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (54.6pt,102.9pt) .. controls (62.1pt,104.3pt) and (70.4pt,105.7pt) .. (78.0pt,106.5pt) .. controls (105.4pt,109.1pt) and (136.4pt,109.2pt) .. (159.9pt,108.7pt) -- (168.2pt,108.5pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (54.6pt,91.0pt) .. controls (62.1pt,89.6pt) and (70.4pt,88.1pt) .. (78.0pt,87.2pt) .. controls (136.5pt,79.8pt) and (206.0pt,77.3pt) .. (241.0pt,76.5pt) -- (249.3pt,76.3pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (32.8pt,88.7pt) .. controls (40.6pt,76.6pt) and (57.2pt,54.4pt) .. (78.0pt,45.6pt) .. controls (155.3pt,13.0pt) and (257.7pt,27.8pt) .. (306.7pt,38.1pt) -- (315.0pt,39.8pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (32.2pt,105.1pt) .. controls (39.5pt,118.7pt) and (56.0pt,145.2pt) .. (78.0pt,158.4pt) .. controls (124.0pt,185.9pt) and (142.9pt,181.4pt) .. (196.5pt,181.4pt) .. controls (196.5pt,181.4pt) and (196.5pt,181.4pt) .. (265.1pt,181.4pt) .. controls (325.0pt,181.4pt) and (387.3pt,138.9pt) .. (413.3pt,118.9pt) -- (419.9pt,113.7pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (31.1pt,88.7pt) .. controls (37.5pt,73.6pt) and (53.4pt,41.7pt) .. (78.0pt,27.1pt) .. controls (123.6pt,0.0pt) and (143.4pt,12.3pt) .. (196.5pt,12.3pt) .. controls (196.5pt,12.3pt) and (196.5pt,12.3pt) .. (265.1pt,12.3pt) .. controls (315.1pt,12.3pt) and (372.1pt,27.6pt) .. (403.0pt,37.2pt) -- (411.0pt,39.8pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (32.8pt,105.0pt) .. controls (40.6pt,117.1pt) and (57.2pt,139.2pt) .. (78.0pt,148.0pt) .. controls (119.7pt,165.5pt) and (253.6pt,131.6pt) .. (310.0pt,116.0pt) -- (318.1pt,113.7pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (145.0pt,100.9pt) .. controls (149.9pt,101.5pt) and (155.0pt,102.1pt) .. (160.0pt,102.7pt) -- (168.2pt,103.7pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (208.5pt,102.2pt) .. controls (218.4pt,97.5pt) and (233.4pt,90.4pt) .. (245.3pt,84.8pt) -- (252.9pt,81.1pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (213.8pt,60.3pt) .. controls (222.2pt,62.9pt) and (232.5pt,66.2pt) .. (241.5pt,69.1pt) -- (249.4pt,71.6pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (274.6pt,71.1pt) .. controls (282.1pt,67.2pt) and (293.0pt,61.8pt) .. (302.8pt,57.5pt) .. controls (306.3pt,56.0pt) and (310.1pt,54.4pt) .. (313.8pt,52.9pt) -- (321.6pt,49.9pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (279.4pt,78.9pt) .. controls (304.6pt,84.1pt) and (357.9pt,94.9pt) .. (392.8pt,102.0pt) -- (401.0pt,103.6pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (279.4pt,73.3pt) .. controls (304.3pt,68.4pt) and (356.7pt,58.3pt) .. (391.6pt,51.5pt) -- (399.8pt,49.9pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (273.9pt,81.1pt) .. controls (281.5pt,85.4pt) and (292.7pt,91.5pt) .. (302.8pt,96.1pt) .. controls (306.2pt,97.6pt) and (309.9pt,99.2pt) .. (313.6pt,100.7pt) -- (321.3pt,103.6pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (343.9pt,49.9pt) .. controls (358.6pt,60.4pt) and (392.9pt,85.0pt) .. (412.1pt,98.8pt) -- (418.9pt,103.7pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (369.8pt,44.9pt) .. controls (375.1pt,44.9pt) and (380.7pt,44.9pt) .. (386.1pt,44.9pt) -- (394.6pt,44.9pt);
  \draw[->, >=stealth, black!70, line width=0.30pt] (49.9pt,302.3pt) .. controls (57.2pt,302.3pt) and (65.7pt,302.3pt) .. (73.9pt,302.3pt) -- (82.3pt,302.3pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.30pt] (357.1pt,125.3pt) .. controls (368.8pt,122.5pt) and (383.7pt,118.9pt) .. (396.7pt,115.7pt) -- (404.8pt,113.8pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.30pt] (49.2pt,323.8pt) .. controls (55.9pt,323.8pt) and (63.5pt,323.8pt) .. (71.0pt,323.8pt) -- (79.4pt,323.8pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.30pt] (37.9pt,249.0pt) .. controls (47.8pt,255.5pt) and (63.4pt,265.3pt) .. (78.0pt,271.9pt) .. controls (80.4pt,272.9pt) and (82.9pt,273.9pt) .. (85.4pt,274.8pt) -- (93.5pt,277.6pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.30pt] (49.2pt,247.3pt) .. controls (59.0pt,249.7pt) and (70.9pt,252.6pt) .. (81.5pt,255.2pt) -- (89.7pt,257.1pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.30pt] (49.2pt,242.2pt) .. controls (57.7pt,242.2pt) and (67.7pt,242.2pt) .. (77.2pt,242.2pt) -- (85.6pt,242.2pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.30pt] (49.2pt,237.0pt) .. controls (59.0pt,234.6pt) and (70.9pt,231.8pt) .. (81.5pt,229.2pt) -- (89.7pt,227.2pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.30pt] (37.9pt,235.3pt) .. controls (47.8pt,228.8pt) and (63.4pt,219.0pt) .. (78.0pt,212.5pt) .. controls (80.1pt,211.6pt) and (82.3pt,210.7pt) .. (84.5pt,209.9pt) -- (92.4pt,207.1pt);
\end{tikzpicture}
Figure 4-1 Dependency graph of the BatchNorm chapter’s theorems.
Theorem 44 BN affine step Jacobian
✓
#

For \(\mathrm{bnAffine}(\gamma , \beta )\, v = \lambda i.\; \gamma v_i + \beta \): prove: \(\operatorname {pdiv}\bigl(\mathrm{bnAffine}(\gamma , \beta )\bigr)\, v\, i\, j = \gamma \, \delta _{ij}\).

Proof ▶
  1. \(\mathrm{bnAffine}(\gamma , \beta ) = (y \mapsto \gamma \cdot y) + (\lambda \_ .\; \beta )\), and \(y \mapsto \gamma \cdot y\) is additive and homogeneous.
    proof: Unfold; mul_add and commutativity of the scalars.

  2. q.e.d.
    proof: Affine rule (Theorem 9) with 1: the entry is \((\gamma \cdot \mathbf{e}_i)_j = \gamma \, \delta _{ij}\).

Theorem 45 BN centering Jacobian
✓
#

For \(\mathrm{bnCentered}\, x = \lambda j.\; x_j - \mu (x)\), where \(\mu (x) = \frac{1}{n}\sum _s x_s\): prove: \(\operatorname {pdiv}(\mathrm{bnCentered})\, x\, i\, j = \delta _{ij} - 1/n\).

Proof ▶
  1. \(\mathrm{bnCentered}\) is linear: additive and homogeneous, because the mean is.
    proof: \(\mu (u + v) = \mu (u) + \mu (v)\) and \(\mu (a\, v) = a\, \mu (v)\) (Finset.sum_add_distrib, Finset.mul_sum).

  2. q.e.d.
    proof: Linear rule (Theorem 9 at \(c = 0\), pdiv_of_linear): the entry is \(\mathrm{bnCentered}(\mathbf{e}_i)_j = (\mathbf{e}_i)_j - \mu (\mathbf{e}_i) = \delta _{ij} - 1/n\), since a basis vector has mean \(1/n\) (Finset.sum_ite_eq’).

Theorem 46 BN inverse-stddev broadcast smoothness
✓
#

assume:

  1. \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]

prove: \(\operatorname {bnIstdBroadcast}= x \mapsto 1/\sqrt{\sigma ^2(x) + \varepsilon }\) is \(\mathsf{Differentiable}\). This is the sqrt/recip smoothness that the product rule needs inside the normalize Jacobian.

Proof ▶
  1. \(\sigma ^2(x) + \varepsilon {\gt} 0\) for every \(x\), in particular \(\neq 0\).
    proof: \(\sigma ^2(x) \ge 0\) (a sum of squares over \(n\)); assumption 1 pushes it strictly positive.

  2. \(x \mapsto \sigma ^2(x) + \varepsilon \) is differentiable.
    proof: Polynomial in the coordinates of \(x\) (fun_prop).

  3. \(x \mapsto \sqrt{\sigma ^2(x) + \varepsilon }\) is differentiable, and nowhere zero.
    proof: Differentiable.sqrt applies away from \(0\), which 1 grants; \(\sqrt{\cdot }\) of a positive is positive.

  4. q.e.d.
    proof: \(\mathrm{istd} = (\sqrt{\sigma ^2 + \varepsilon })^{-1}\); Differentiable.inv with the nonvanishing denominator from 3.

Theorem 47 BN inverse-stddev broadcast Jacobian
✓
#

assume:

  1. \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]

prove: writing \(s := \mathrm{istd}(x, \varepsilon ) = 1/\sqrt{\sigma ^2(x) + \varepsilon }\):

\[ \operatorname {pdiv}(\operatorname {bnIstdBroadcast})\, x\, i\, j = -s^3 \cdot (x_i - \mu )/n. \]
Proof ▶

Sketch: the one genuinely analytic Jacobian of the chapter — a Fréchet-derivative chain through variance, \(\sqrt{\cdot }\), and \(({\cdot })^{-1}\), closed by the \(\sum _k (x_k - \mu ) = 0\) identity.

  1. \(\sigma ^2(x) + \varepsilon {\gt} 0\), so \(\sqrt{\sigma ^2(x) + \varepsilon } {\gt} 0\).
    proof: Sum of squares \(\ge 0\) plus assumption 1.

  2. \(\operatorname {pdiv}(\operatorname {bnIstdBroadcast})\, x\, i\, j = \operatorname {fderiv}_{\mathbb {R}}\, \bigl(x' \mapsto \mathrm{istd}(x', \varepsilon )\bigr)\, x\, (\mathbf{e}_i)\) — the output is constant in \(j\), so \(\operatorname {pdiv}\) reduces to the scalar derivative.
    proof: pdiv_eq_fderiv_coord: at a differentiable point, \(\operatorname {pdiv}\) at \((i, j)\) is the Fréchet derivative of the \(j\)-th coordinate function at \(\mathbf{e}_i\); legitimate by Theorem 46.

  3. Derivative chain. Let \(C_k := \mathrm{proj}_k - \tfrac {1}{n}\sum _{i'} \mathrm{proj}_{i'}\) be the centering CLM (so \(C_k\, y = y_k - \mu (y)\)). Then \(\sigma ^2 = \tfrac {1}{n}\sum _k C_k^2\) has Fréchet derivative \(\tfrac {1}{n}\sum _k 2\, C_k(x) \cdot C_k\), and composing through \(\sqrt{\cdot }\) (HasFDerivAt.sqrt, licensed by 1) and \(({\cdot })^{-1}\) (hasDerivAt_inv) gives \(\partial \, \mathrm{istd} / \partial \sigma ^2 = -\tfrac {1}{2}\, s^3\).
    proof: Product rule per square, summed; the two Mathlib compositions.

  4. Evaluate at \(\mathbf{e}_i\): \(\partial \sigma ^2 / \partial x_i = \tfrac {2}{n}(x_i - \mu )\).
    proof: \(C_k(\mathbf{e}_i) = \delta _{ki} - \tfrac {1}{n}\), so the sum in 3 is \(\tfrac {2}{n} \sum _k (x_k - \mu )(\delta _{ki} - \tfrac {1}{n})\), which collapses to \(\tfrac {2}{n}(x_i - \mu )\) because \(\sum _k (x_k - \mu ) = 0\).

  5. q.e.d.
    proof: Chain 3 and 4: \(-\tfrac {1}{2}\, s^3 \cdot \tfrac {2}{n}(x_i - \mu ) = -s^3 (x_i - \mu )/n\); with 2 this is the goal.

Theorem 48 BN normalize 3-term VJP
✓

assume:

  1. \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]

prove: \(\mathsf{HasVJP}\, (\mathrm{bnNormalize})\), with the consolidated three-term backward (writing \(s := \mathrm{istd}\), \(\hat{x} := \mathrm{bnXhat}\)):

\[ B(x, d\hat{x})_i = \tfrac {1}{n}\, s \Bigl( n\, d\hat{x}_i - \sum _j d\hat{x}_j - \hat{x}_i \sum _j \hat{x}_j\, d\hat{x}_j \Bigr). \]
Proof ▶

Sketch: product rule on \(\hat{x} = (x - \mu ) \cdot s\) merges the two elementary Jacobians into one formula; contracting with \(d\hat{x}\) splits it into the three terms.

  1. Consolidated Jacobian: \(\operatorname {pdiv}(\mathrm{bnNormalize})\, x\, i\, j = \tfrac {s}{n}\bigl(n\, \delta _{ij} - 1 - \hat{x}_i \hat{x}_j\bigr)\).
    proof: Factor \(\hat{x}\) as the elementwise product \(\mathrm{bnCentered} \cdot \operatorname {bnIstdBroadcast}\) (bnXhat_eq_product); product rule (Theorem 4) — differentiable because \(\mathrm{bnCentered}\) is affine and \(\operatorname {bnIstdBroadcast}\) is smooth (Theorem 46, assumption 1); substitute the centering Jacobian (Theorem 45) and the istd Jacobian (Theorem 47); the \(\hat{x} = (x - \mu ) \cdot s\) identity plus field_simp/ring collapse the algebra (\(n \neq 0\) since \(\mathrm{Fin}\, n\) is inhabited by \(i\)). This step is the Lean lemma pdiv_bnNormalize.

  2. suffices: for all \(x\), \(d\hat{x}\), \(i\): \(B(x, d\hat{x})_i = \sum _j \operatorname {pdiv}(\mathrm{bnNormalize})\, x\, i\, j \cdot d\hat{x}_j\).
    proof: Definition 10, with \(B\) as the candidate backward function.

  3. q.e.d.
    proof: Substitute 1 into 2 and split the sum into its three pieces: the \(\delta \)-term collapses to \(n\, d\hat{x}_i\) (Finset.sum_ite_eq), the \(-1\) term gives \(-\sum _j d\hat{x}_j\), and factoring \(\hat{x}_i\) out of the third gives \(-\hat{x}_i \sum _j \hat{x}_j d\hat{x}_j\); scale by \(s/n\) and this is \(B\).

Theorem 49 BN affine VJP
✓

\(\mathsf{HasVJP}\, \bigl(\mathrm{bnAffine}(\gamma , \beta )\bigr)\): each input feeds one output scaled by \(\gamma \), so the gradient comes back scaled by \(\gamma \).

Proof ▶
  1. Define \(B(v, dy)_i := \gamma \cdot dy_i\).

  2. suffices: for all \(v\), \(dy\), \(i\): \(B(v, dy)_i = \sum _j \operatorname {pdiv}\bigl(\mathrm{bnAffine}(\gamma , \beta )\bigr)\, v\, i\, j \cdot dy_j\).
    proof: Definition 10, with \(B\) as the candidate backward function.

  3. q.e.d.
    proof: By the affine Jacobian (Theorem 44) the sum is \(\sum _j \gamma \, \delta _{ij}\, dy_j = \gamma \, dy_i\).

Theorem 50 Full BN VJP
✓

assume:

  1. \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]

prove: \(\mathsf{HasVJP}\, \bigl(\mathrm{bnForward}(\varepsilon , \gamma , \beta )\bigr)\).

Proof ▶
  1. \(\mathrm{bnForward} = \mathrm{bnAffine} \circ \mathrm{bnNormalize}\).
    proof: Definitional (bnForward_eq_compose).

  2. \(\mathrm{bnNormalize}\) is differentiable everywhere.
    proof: It is the elementwise product of \(\mathrm{bnCentered}\) (affine, hence smooth) and \(\operatorname {bnIstdBroadcast}\) (smooth by Theorem 46, assumption 1).

  3. \(\mathrm{bnAffine}\) is differentiable everywhere.
    proof: Affine (fun_prop).

  4. q.e.d.
    proof: VJP chain rule (Theorem 11) with 2, 3 and the two halves (Theorems 48, 49). The composed backward is exactly the two-step MLIR backward: \(d\hat{x} = \gamma \cdot dy\), then the three-term formula.

assume:

  1. \(\varepsilon {\gt} 0\) [h\(\varepsilon \)]

prove: on the network’s \([N, C, H, W]\) layout, with each channel normalized over all \(N \cdot H \cdot W\) of its cells, the batch-statistics layer bnBatchTensor4 is differentiable and has a VJP, and its renderable backward, bnBatchTensor4GradInput, is that VJP: at every input and cotangent it equals the layer’s Jacobian contracted with the cotangent. This is the form the batch-statistics steps of this chapter and every BatchNorm net from Chapter 5 on render.

Proof ▶
  1. The layer is the flat per-channel layer conjugated by a relabelling of the layout, \(\mathrm{bnchwBack} \circ \mathrm{bnPerChannelFlat} \circ \mathrm{bnchwFwd}\). The relabelling takes \([N, C, H, W]\) to \([C,\; N H W]\); each channel’s row is then normalized with Theorem 50’s layer at group size \(n = N H W\), and relabelled back.
    proof: Definitional.

  2. Both relabellings are linear bijections, so each is differentiable and has a VJP, the inverse relabelling.
    proof: bnchwFwdHasVJP, bnchwBackHasVJP.

  3. q.e.d.
    proof: The VJP chain rule (Theorem 11) twice, with Theorem 50 per channel in the middle. Unfolding the composed backward gives the per-channel three-term formula over the batch, which is \(\mathrm{bnBatchTensor4GradInput}\).

The network, at two depths: the two-convolution CIFAR net’s fold and loss gradient, BatchNorm’s two updates, then the eight-convolution net with and without BatchNorm — its fused step, the packed steps this chapter’s runs train (the same backward chain handing each gradient to a separate optimizer), the same steps with BatchNorm over the batch, their loss gradients, and one descent step at the last convolution.

Theorem 52 Emitted updates are the certified steps
✓

prove: all fourteen parameter updates cifar_train_step.mlir emits — weights and biases of the three dense layers and of the two-stage CIFAR net’s four convolutions — denote the certified step at the real forward cifarCnnForward, each at the chain cotangent carried back from the softmax cross-entropy gradient. Stated at one example; the committed text is the batch-128 instance read one image at a time, and the eight-convolution ties below are batched.

Proof ▶

A fold over the fourteen updates, as for the MNIST CNN, through two pools instead of one; see Proofs.CifarFold.cifar_train_step_tied_certified.

assume:

  1. odd kernels [hkH, hkW]

  2. at \(x\), every ReLU is off its corner, and at each of the two pools every window is entirely \(\le 0\) before its ReLU or has its maximum strictly above every other cell except its twins, cells equal at every weight upstream of that pool [hx]

  3. a selection per pool names a maximal cell of each window

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

prove: each of the fourteen certified gradients the fused updates of Theorem 52 subtract, at the chain cotangent with each pool routed at its selection, is \(\partial L/\partial \theta \) of cifarCnnForward with that one parameter varied. Instantiated at the softmax cross-entropy with the emitted loss cotangent.

Proof ▶

As for the MNIST net; a first-stage parameter moves both pools, and its germ replaces the outer one first. See Proofs.CifarFold.cifar_net_lossGrad.

Theorem 54 Emitted BatchNorm updates are the certified steps
✓

prove: for a per-channel BatchNorm at any spatial shape and any cotangent \(c\), the two emitted updates denote the certified steps \(\gamma - \alpha \sum \operatorname {pdiv}(\mathrm{bn})\cdot c\) and \(\beta - \alpha \sum \operatorname {pdiv}(\mathrm{bn})\cdot c\) — the scale and shift each take the gradient of Theorem 50’s forward.

Proof ▶

The two updates are \(\gamma \)’s and \(\beta \)’s gradients of Theorem 50’s forward at the cotangent, by the definition of their denotation; see Proofs.SgdNode.bnSgdPairTied_holds.

Theorem 55 The eight-convolution step is the certified step
✓

prove: all twenty-two parameter updates of cifar8_train_step.mlir — kernels and biases of \(W_1, \dots , W_8\) across the four stages, and weights and biases of the three dense layers — denote the certified step at cifarCnn8Forward, each at the chain cotangent carried back from the softmax cross-entropy gradient, through the dense head and then stage by stage.

Proof ▶

A fold over the twenty-two updates, the dense head first and then the four stages in reverse; see Proofs.Cifar8Tie.cifar8_train_step_tied_certified.

Theorem 56 The eight-convolution BatchNorm step is the certified step
✓

prove: with BatchNorm after each of the eight convolutions, all thirty-eight parameter updates of cifar8_bn_train_step.mlir — kernel, bias, and BatchNorm \((\gamma , \beta )\) per convolution, and weight and bias per dense layer — denote the certified step at cifarCnnBn8Forward: the dense updates at the cotangent the head carries back, the BatchNorm updates at the ReLU-masked cotangent, the convolution updates at the cotangent BatchNorm’s input gradient passes down.

Proof ▶

A fold over the thirty-eight updates, the BatchNorm pair of Theorem 54 at each convolution; see Proofs.Cifar8BnTie.cifar8Bn_train_step_tied_certified.

prove: every gradient node of the packed steps cifar8w_{sgd,mom,adam} and cifar8w_bn_{sgd,mom,adam} — twenty-two without BatchNorm and thirty-eight with it — denotes the certified per-layer Jacobian contracted with the chain cotangent. The optimizer update that consumes the node (SGD, Nesterov or AdamW) is outside the statement.

Proof ▶

Proofs.Cifar8TieG.cifar8_train_step_tiedG and Proofs.Cifar8BnTieG.cifar8Bn_train_step_tiedG are the fused ties’ chain, node for node, with the update peeled off.

Theorem 58 The batch-statistics steps’ gradients are the certified batch gradients
✓

prove: every one of the thirty-eight gradient nodes of the packed steps cifar8wb_bbn_* — the eight-convolution net with each BatchNorm reducing over the batch and the spatial dimensions, as ResNet-34’s do — denotes at batch \(N\) the certified batched gradient: the layer’s Jacobian contracted with the cotangent the rendered backward chain delivers, summed over the batch, at either precision, with the batch-mean softmax cross-entropy at any target. The batch is coupled through the BatchNorms, so there is no per-example reading. That the chain delivering those cotangents is the certified whole-net backward is Theorem 59, and that each node is the loss’s derivative in its parameter is Theorem 60.

Proof ▶

Proofs.Cifar8BbnTieB.cifar8Bbn_train_step_tiedB is thirty-eight node ties in the batched vocabulary of Theorem 68 — convolution weight and bias, BatchNorm pair, dense weight and bias — at the render’s own chain.

assume:

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

  2. at \(x\), each of the eight pre-ReLU activations is off zero, each of the four pools has a unique maximum in every window of every example, and the two head pre-activations are off zero [hs]

prove: the backward chain the cifar8wb_bbn steps render — the three dense backs with the head’s two ReLU masks between them, then per stage the pool scatter and twice the ReLU mask, the batch-statistics BatchNorm backward and the convolution input-VJP — is the backward of the certified VJP of the whole batched forward, and so at every batch size \(N\), every logits cotangent \(dy\) and every input coordinate \(i\) equals

\[ \sum _j \operatorname {pdiv}(\mathrm{cifar8BbnForwardB})\, x\, i\, j \cdot dy_j , \]

the Jacobian of the net contracted with the cotangent. The forward whose Jacobian this is, is the seventeen-layer composite the net’s certified layer computes.

Proof ▶

The net is seventeen certified layers composed: eight conv–BN–ReLU stages (Theorem 51 inside each), four batched \(2 \times 2\) pools (the per-example pool VJP lifted over the batch), three dense layers and two ReLUs. Composition of certified layers is certified, so the composite’s backward is the nested stage backwards; each stage’s backward is read in the chain’s vocabulary (the ReLU mask, the BatchNorm backward, the convolution input-VJP, the pool scatter), and the chain is that reading. The \(\operatorname {pdiv}\) form is the composite VJP’s own correctness; see Proofs.cifar8BbnInputGradB_correct.

assume:

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

  2. at \(x\), every ReLU is off its corner, and at each of the four pools every window of every example is entirely \(\le 0\) before its ReLU or has its maximum strictly above every other cell except its twins, cells equal at every weight upstream of that pool [hx]

  3. a selection per pool names, for every example, a maximal cell of each window

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

prove: each of the thirty-eight gradient nodes of Theorem 58, at the chain cotangent with each pool routed at its selection, is \(\partial L/\partial \theta \) of the batched forward with that one parameter varied: the loss is differentiable in the parameter, and the node is its gradient. Instantiated at the batch-mean softmax cross-entropy at the graph’s soft target, with the emitted loss cotangent. The render’s own routing, each window’s cotangent to its first maximal cell, is one such selection, so the chain of Theorem 58 is a chain this theorem is about.

Proof ▶

The per-image argument of Theorem 61, lifted over the batch. Each pool is frozen at its selection, which makes it a gather, linear and smooth. Along any one parameter the pooled ReLU equals that gather near the point: a dead window stays dead, a strict maximum stays strict, and twins stay tied. The loss through the gather model is pulled back one certified op at a time, dense and ReLU through the head, then gather, BatchNorm and convolution through the four stages, and lands on the chain’s own cotangent at every layer. Each node’s lemma reads that cotangent as the derivative in its parameter, and the germ carries the result from the gather model to the real net; see Proofs.cifar8Bbn_net_lossGrad.

assume:

  1. odd kernels [hkH, hkW]

  2. every BatchNorm’s \(\varepsilon {\gt} 0\) [hq]

  3. at \(x\), every ReLU is off its corner, and at each of the four pools every window is entirely \(\le 0\) before its ReLU or has its maximum strictly above every other cell except its twins, cells equal at every weight upstream of that pool [hx]

  4. a selection per pool names a maximal cell of each window

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

prove: each gradient node of Theorem 57, at the chain cotangent with each pool routed at its selection, is \(\partial L/\partial \theta \) of the forward with that one parameter varied. Instantiated at the softmax cross-entropy with the emitted loss cotangent.

Proof ▶

Proofs.Cifar8TieG.cifar8_net_lossGrad is the net without BatchNorm: as for the MNIST net, stage by stage through the four pools.

Proofs.Cifar8BnTieG.cifar8Bn_net_lossGrad is the same for the BatchNorm net.

Theorem 62 SGD descends at the last convolution
✓

assume:

  1. the first seven convolutions are frozen, \(x_1\) their output [hx\(_1\)]

  2. the hypotheses of Theorem 43 at that input: the bounds, the margins up to twins, the \(\eta \)-accurate oracle, the small-step and dominance conditions [hx, hT, hm2, hmq, hm3, hm4, hgh, hsmall, h1, h2]

prove: one SGD step on the last convolution’s kernel decreases the CIFAR-8 cross-entropy loss:

\[ L(W_8 - \alpha g) \le L(W_8) - \alpha \, \| \nabla L\| _2^2/2 . \]
Proof ▶

Proofs.cifar8_lastConv_sgd_descends is Theorem 43 at the factored forward, the frozen prefix as its input.

Theorem 63 The optimizer tails are their update laws
✓

prove: the per-parameter update operations the renders of §4.3 emit denote the update laws of that ablation, applied to whatever gradient node \(g\) they consume, with the learning rate a runtime argument:

  1. Nesterov momentum: the two emitted ops denote \((\theta - \mathrm{lr}\, (g + \mu v'),\; v')\) with \(v' = \mu v + g\). The heavy-ball step \(\theta - \mathrm{lr}\, v'\) differs from it by exactly \(\mathrm{lr}\, \mu \, (v' - v)\) per coordinate.

  2. AdamW: the three emitted ops denote \((\theta ', m', v')\) with \(m' = \beta _1 m + (1 - \beta _1)\, g\), \(v' = \beta _2 v + (1 - \beta _2)\, g^2\) and

    \[ \theta ' = \theta - \mathrm{lr}\, \frac{m'/b_1}{\sqrt{v'/b_2} + \varepsilon } - \mathrm{lr}\, \mathrm{wd}\, \theta , \]

    the bias corrections \(b_1, b_2\) host-computed per step. The denominator is positive for every \(\varepsilon {\gt} 0\).

Plain SGD’s single fused op is inside Theorems 55 and 56 already. Neither law here comes with a descent bound; the claim is that the emitted arithmetic is the law, and nothing about where it goes.

Proof ▶

Both faithfulness statements are rfl: the denotation of the emitted ops is, term for term, the definition of the step; see Proofs.StableHLO.adamW_triple_faithful.

4.3 Example: the eight-conv CNN on CIFAR-10

Chapter 3 did MNIST with two convolutions and a 2\(\times \)512 dense head. This chapter keeps that exact head and the same conv/ReLU/max-pool machinery, but stacks the convolutions four stages deep, eight \(3 \times 3\) convolutions in all, because CIFAR-10 is a genuinely harder problem. Same head, four times the body. That extra depth is the whole point of the chapter: it is where BatchNorm starts to pay, where the optimizer starts to matter, and the same depth ResNet-34 will scale to thirty-four layers. It also lets us ask a sharper question than “does it train?” We can ask “what governs how it trains?”

Two levers govern the answer, normalization and the optimizer; a third, the arithmetic the whole thing is computed in, turns out not to move it at all — which is its own kind of finding — and a knob, the width of the head, does not either. We move each against an identical, machine-checked gradient, holding everything else fixed: same architecture, same 40 epochs, the same data pipeline (per-epoch shuffle, random horizontal flip) and one constant learning rate. The findings, stated up front so the graphs are no surprise:

  • Normalization buys accuracy, and speed on the way. BatchNorm (dropped between each conv and its ReLU) reaches a given accuracy in markedly fewer epochs and finishes three to eight points higher. What it does not buy is repeatability: five runs of the batch-statistics net spread over 2.2 to 3.0 points, about what the un-normalized net’s 2.7 to 3.3 are. The per-image form lands within 1.1, and the reason is where its statistics come from, not that it normalizes.

  • The optimizer moves the ceiling less than the normalization does. Momentum finishes first on both normalized nets, but its lead over plain SGD is half a point at batch statistics, inside the spread of either. AdamW lands a point under SGD once BN is in, and its per-coordinate adaptivity earns less than momentum does.

  • The arithmetic does not move the ranking. Recomputing the normalized ladder in bf16 leaves the optimizer ordering exactly where it was, with medians moving by \(-0.21\), \(+0.57\) and \(+1.02\) — every one of them smaller than the spread between repeated runs of a single configuration. Drop the normalization and bf16 starts to show: the medians move by up to \(1.4\) points and one seed collapses. The eight-layer stack is fragile, and the same runs show it, but it is fragile in fp32 too. What breaks the network is depth without normalization, not the number format.

  • The head barely matters. This 2\(\times \)512 head carries about 25\(\times \) the parameters of a narrow 64-wide one, yet trains to within a point of it. The convolutional body, not the head, is doing the work. That is also why we can borrow MNIST’s head wholesale: it was never the bottleneck.

Every run below shares one proof-rendered backward pass, and only the BN layers or the rendered optimizer tail change. The numbers are from that verified training step running on a GPU.

On repeatability. Convolutional networks do not reproduce run to run on CUDA, because XLA selects convolution algorithms per process, so two runs of the identical command differ. Every accuracy in this section is therefore the median of five runs at seeds 1–5 (LEAN_MLIR_SEED, which moves the initialisation, the shuffle and the flips), and each table states the observed range alongside it. That spread is seed-to-seed variance plus the nondeterminism: two to three points at batch statistics and without normalization, about a point per image, which is wide enough to swallow small differences, so the text below only ranks gaps that are clearly outside it.

Architecture

The same vertical column as the MNIST CNN in Chapter 3, just deeper: eight \(3 \times 3\) convolutions in four stages (a max-pool after each) lift the \(32 \times 32 \times 3\) input to a \(2 \times 2 \times 32\) feature grid, which flattens to 128 and runs through the same 2\(\times \)512 dense head as that net (only the first layer changes width, \(6272\) vs. \(128\), because the deeper stack pools the grid down further). Eight convolutions is four times the MNIST net’s two, and that growth in depth, not width, is the lineage ResNet-34 (Ch 5) carries to thirty-four layers. Figure 4-2 is the network.

\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=4.7cm},
  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},
  flat/.style   = {col, draw=purple!60!black, fill=purple!8},
  dense/.style  = {col, draw=orange!65!black, fill=orange!12},
  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},
]
  % Vertical layer column: input at top -> logits at bottom.
  \node[io]                            (input) {Input \;\; $32\times32\times3$};
  \node[convbn, below=0.18cm of input] (c1)   {\textbf{ConvBN} $3\to16$, $3\times3$, ReLU};
  \node[convbn, below=0.18cm of c1]    (c2)   {\textbf{ConvBN} $16\to16$, $3\times3$, ReLU};
  \node[pool,   below=0.18cm of c2]    (p1)   {\textbf{maxPool} $2\times2$ \;\; $32\to16$};
  \node[convbn, below=0.18cm of p1]    (c3)   {\textbf{ConvBN} $16\to16$, $3\times3$, ReLU};
  \node[convbn, below=0.18cm of c3]    (c4)   {\textbf{ConvBN} $16\to16$, $3\times3$, ReLU};
  \node[pool,   below=0.18cm of c4]    (p2)   {\textbf{maxPool} $2\times2$ \;\; $16\to8$};
  \node[convbn, below=0.18cm of p2]    (c5)   {\textbf{ConvBN} $16\to32$, $3\times3$, ReLU};
  \node[convbn, below=0.18cm of c5]    (c6)   {\textbf{ConvBN} $32\to32$, $3\times3$, ReLU};
  \node[pool,   below=0.18cm of c6]    (p3)   {\textbf{maxPool} $2\times2$ \;\; $8\to4$};
  \node[convbn, below=0.18cm of p3]    (c7)   {\textbf{ConvBN} $32\to32$, $3\times3$, ReLU};
  \node[convbn, below=0.18cm of c7]    (c8)   {\textbf{ConvBN} $32\to32$, $3\times3$, ReLU};
  \node[pool,   below=0.18cm of c8]    (p4)   {\textbf{maxPool} $2\times2$ \;\; $4\to2$};
  \node[flat,   below=0.18cm of p4]    (fl)   {flatten \;\; $2\times2\times32 \to 128$};
  \node[dense,  below=0.18cm of fl]    (d1)   {\textbf{Dense} $128\to512$, ReLU};
  \node[dense,  below=0.18cm of d1]    (d2)   {\textbf{Dense} $512\to512$, ReLU};
  \node[head,   below=0.18cm of d2]    (d3)   {\textbf{Dense} $512\to10$ \;(identity)};
  \node[logits, below=0.18cm of d3]    (out)  {Logits \;\; 10 classes, softmax-CE};
  \foreach \a/\b in {input/c1, c1/c2, c2/p1, p1/c3, c3/c4, c4/p2, p2/c5,
                     c5/c6, c6/p3, p3/c7, c7/c8, c8/p4, p4/fl, fl/d1,
                     d1/d2, d2/d3, d3/out}
     \draw[arr] (\a) -- (\b);
  % Stage brackets on the right.
  \node[stage] at ($(c1.east)!0.5!(p1.east) + (0.35,0)$) {stage 1};
  \node[stage] at ($(c3.east)!0.5!(p2.east) + (0.35,0)$) {stage 2};
  \node[stage] at ($(c5.east)!0.5!(p3.east) + (0.35,0)$) {stage 3};
  \node[stage] at ($(c7.east)!0.5!(p4.east) + (0.35,0)$) {stage 4};
  % Bridge cue: the dense head is exactly the MNIST CNN's.
  \node[stage, align=left] at ($(d1.east)!0.5!(d2.east) + (0.35,0)$) {= MNIST CNN\\head (Ch~\ref{chap:cnn})};
\end{tikzpicture}
Figure 4-2 The eight-convolution CIFAR-10 network with BatchNorm; the ConvBN boxes are the with-BN variant.

The no-BN net is identical with each ConvBN replaced by a plain Conv2D + ReLU. Both specs are written out next.

The two specs, differing by one keyword per layer

Eight \(3 \times 3\) convolutions in four stages (channel widths \(16, 16, 32, 32\), a max-pool after each stage), then the 2\(\times \)512 dense head lifted straight from the MNIST CNN. Without BN, each convolution is followed by a plain ReLU. With BN, a per-channel BatchNorm sits between the convolution and the ReLU. That one keyword per conv layer is the entire difference.

Without BN:

def cifar8wVerified : VerifiedNetSpec where
  name     := "CIFAR-CNN8-wide"
  slug     := "cifar8w"
  inC      := 3
  imageH   := 32
  imageW   := 32
  data     := .cifar
  layers   := [.conv 3 16 3 1, .relu,
               .conv 16 16 3 1, .relu, .maxPool 2 2,
               .conv 16 16 3 1, .relu,
               .conv 16 16 3 1, .relu, .maxPool 2 2,
               .conv 16 32 3 1, .relu,
               .conv 32 32 3 1, .relu, .maxPool 2 2,
               .conv 32 32 3 1, .relu,
               .conv 32 32 3 1, .relu, .maxPool 2 2,
               .flatten,
               .dense 128 512, .relu, .dense 512 512, .relu, .dense 512 10]
  blurb    := "Deeper CIFAR-10 CNN, MNIST-style wide head (8 convs,
                [16,16,32,32], 4 pools 32→2 → 128→512→512→10)
                via the VERIFIED renderer → %LOWERER% → GPU"

With BN:

def cifar8wBnVerified : VerifiedNetSpec where
  name     := "CIFAR-CNN8-wide-BN"
  slug     := "cifar8w_bn"
  inC      := 3
  imageH   := 32
  imageW   := 32
  data     := .cifar
  layers   := [.conv 3 16 3 1, .bnPerChannel 16, .relu,
               .conv 16 16 3 1, .bnPerChannel 16, .relu, .maxPool 2 2,
               .conv 16 16 3 1, .bnPerChannel 16, .relu,
               .conv 16 16 3 1, .bnPerChannel 16, .relu, .maxPool 2 2,
               .conv 16 32 3 1, .bnPerChannel 32, .relu,
               .conv 32 32 3 1, .bnPerChannel 32, .relu, .maxPool 2 2,
               .conv 32 32 3 1, .bnPerChannel 32, .relu,
               .conv 32 32 3 1, .bnPerChannel 32, .relu, .maxPool 2 2,
               .flatten,
               .dense 128 512, .relu, .dense 512 512, .relu, .dense 512 10]
  blurb    := "Deeper CIFAR-10 CNN + per-channel BatchNorm, MNIST-style
                wide head (8× conv→BN→relu → 128→512→512→10)
                via the VERIFIED renderer → %LOWERER% → GPU"

At batch statistics, the spec the transcript in §4.1 trained is the second one with a single field changed:

def cifar8wbBbnVerified : VerifiedNetSpec :=
  { cifar8wBnVerified with
    name      := "CIFAR-CNN8-wide-BN-batchstat"
    slug      := "cifar8wb_bbn"
    runningBN := true
    blurb     := "Wide-head CIFAR-10 CNN + per-channel BatchNorm at BATCH statistics
                  (8 convs, BATCHED op family, d1=512, running-stat eval)
                  via the VERIFIED renderer → %LOWERER% → GPU" }

Each layers list and each blurb is one line in the source, wrapped here; the third blurb is the banner §4.1’s transcript opens with. The diff between the first two specs is eight .bnPerChannel entries, one inserted between each convolution and its ReLU. The diff between the second and the third is runningBN: the group each of those eight normalizes over, and whether the net keeps running statistics. The dense head, the max-pools, and the training config are all identical.

What .bnPerChannel contributes.

One arm, and the only new one this chapter needs — .conv, .maxPool, .flatten, .dense and .relu are all Chapter 3’s, unchanged:

  | bnPerChannel oc => #[(#[oc],1),(#[oc],2)]   -- gamma (ones), beta (zeros)

This is where initKind stops being bookkeeping. BatchNorm’s \(\gamma \) initialises to ones and \(\beta \) to zeros, so a freshly built net’s BN is the identity and the surrounding convolutions see exactly what they would have seen without it. Zero-init \(\gamma \) instead and the whole trunk outputs \(\beta \) regardless of its input. Note also what is not here: running mean and variance. They are state, not parameters — no gradient reaches them — so the renderer threads them through separate bnChannels slots rather than this list, sixteen of them for the batch-statistics spec, derived from runningBN and the eight widths. The per-image spec leaves runningBN off and has no slots, because its statistics never leave the example. The ImageNet nets of Chapters 5–7 are the batch-statistics form throughout.

The spec-to-proof tie. These are VerifiedNetSpecs, which is what makes the listing above binding rather than descriptive. The slug names the committed render, so cifar8wb_bbn names the six cifar8wb_bbn_*_train_step.mlir files in verified_mlir/, the ones the run in §4.1 handed to XLA, and cifar8w_bn names the per-image set. The layers list folds to a parameter layout through toSpecs, and a #guard in the same file pins that layout against the one the renderer emits, so a spec that drifts from its render fails the build rather than training something else. The gradient itself is Proofs.cifarCnnBn8HasVJPAt, proved once and parametrically in the head width, which is why the \(512\)-wide head here and the \(64\)-wide one behind cifar8-bn-verified need no separate proof between them. cifarCnnBn8HasVJPAt_correct (Proofs/Nets/Small/CifarCNN.lean) is the statement that its backward is the derivative, and Proofs/Nets/Small/Cifar8BnStepTie.lean carries that through to the emitted per-image training step. The batch-statistics step is tied node by node in Theorem 58, its backward chain is the certified whole-net backward in Theorem 59, and each node is the loss’s derivative in its parameter in Theorem 60. All three nets run the same XLA/PJRT path on the GPU.

Lever 1: normalization

Fix the optimizer at plain SGD and change the normalization: none, per image, batch statistics. All three nets train for 40 epochs on the shared pipeline at a constant learning rate — no warmup, no decay — because a schedule is a fourth thing changing and this section is about the third. Figure 4-3 is the per-epoch test accuracy, the median of five runs each.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Test accuracy (\%)},
    xmin=0, xmax=41, ymin=10, ymax=80,
    xtick={0,5,10,15,20,25,30,35,40},
    ytick={10,20,30,40,50,60,70,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},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(1,43.54) (2,54.91) (3,60.53) (4,59.38) (5,56.87) (6,66.06) (7,69.18) (8,71.82) (9,69.22) (10,72.38) (11,72.92) (12,72.64) (13,73.12) (14,73.79) (15,74.66) (16,71.78) (17,73.33) (18,74.28) (19,74.34) (20,75.92) (21,75.54) (22,75.70) (23,75.12) (24,75.29) (25,75.16) (26,74.51) (27,75.41) (28,74.96) (29,76.63) (30,76.48) (31,76.12) (32,76.55) (33,76.89) (34,75.83) (35,76.24) (36,76.70) (37,75.77) (38,77.30) (39,76.14) (40,76.78)
};
\addlegendentry{BN, batch statistics}
\addplot[teal!60!black, mark=*, mark options={fill=teal!60!black}] coordinates {
(1,41.19) (2,52.47) (3,55.65) (4,59.07) (5,64.14) (6,65.53) (7,66.81) (8,68.33) (9,66.86) (10,70.38) (11,71.04) (12,72.00) (13,71.77) (14,71.47) (15,72.30) (16,73.05) (17,72.70) (18,72.85) (19,73.04) (20,73.69) (21,73.64) (22,73.81) (23,74.62) (24,72.84) (25,73.95) (26,73.99) (27,74.04) (28,73.90) (29,74.25) (30,74.75) (31,74.50) (32,75.29) (33,74.77) (34,74.73) (35,74.27) (36,74.04) (37,74.48) (38,74.76) (39,74.78) (40,74.50)
};
\addlegendentry{BN, per image}
\addplot[orange, mark=*, mark options={fill=orange}] coordinates {
(1,13.43) (2,34.55) (3,40.84) (4,43.54) (5,49.04) (6,48.40) (7,53.08) (8,53.52) (9,52.90) (10,58.18) (11,58.84) (12,58.73) (13,60.17) (14,59.54) (15,60.90) (16,64.71) (17,63.81) (18,63.73) (19,65.63) (20,67.15) (21,65.10) (22,65.79) (23,67.21) (24,66.78) (25,66.26) (26,68.24) (27,67.75) (28,68.92) (29,67.95) (30,68.60) (31,69.62) (32,69.24) (33,68.86) (34,69.08) (35,68.34) (36,68.86) (37,68.39) (38,69.48) (39,70.15) (40,68.75)
};
\addlegendentry{no BN}
\end{axis}
\end{tikzpicture}
Figure 4-3 CIFAR-10 test accuracy per epoch under three normalizations: the wide 8-conv net, plain SGD at a constant lr 0.1 on the shared pipeline, 40 epochs, through the verified renderer, the median of five runs per curve (runs/2026-10-24-bbn-seeds/ for batch statistics, runs/2026-09-01-cifar8w-6arm-constlr/ for the other two).

The final points span 74.9 to 77.9 at batch statistics, 74.1 to 75.2 per image, and 68.0 to 70.7 without.

Both normalized nets lead from the first epoch — 44% and 41% against the bare net’s 13% — and never give the lead back, the batch form finishing 8.0 points up (76.8% vs 68.8%) and the per-image form 5.8. The un-normalized net traces the same arc roughly ten epochs behind, and the gap never closes: it is still losing at epoch 40 by more than the spread of any set of runs. The batch curve is also the jagged one: its test reads swing where the per-image curve’s do not, because its test forward runs on running statistics that lag the weights, while the per-image net evaluates with the statistics it trained on.

That first epoch is worth a second look. Thirteen per cent is barely above chance on ten classes, and the un-normalized net spends four epochs getting to where the normalized ones start. The conditioning the three-term backward (Theorem 48) buys is what that is, and it is worth more the deeper the stack — exactly the trend that makes BN standard equipment by ResNet’s thirty-four layers.

Lever 2: the optimizer

Now hold the architecture fixed and change only the update rule. Three optimizers, each at its own tuned learning rate, all on the identical pipeline, the same 40 epochs and the same constant rate. Table 4-1 is the median final accuracy, with the observed range across five runs.

Table 4-1 Final CIFAR-10 test accuracy by optimizer, without BatchNorm and with it in both forms: the median of five runs, with the range from lowest run to highest in parentheses (runs/2026-09-01-cifar8w-6arm-constlr/, runs/2026-10-24-bbn-seeds/).
 

SGD

momentum

AdamW

 

(lr 0.1)

(\(\mu \) 0.9, lr 0.02)

(lr \(10^{-3}\))

no BN

68.8 (2.7)

72.2 (3.3)

72.8 (2.8)

BN, per image

74.5 (1.1)

76.3 (1.0)

74.3 (0.2)

BN, batch statistics

76.8 (3.0)

77.4 (2.2)

75.7 (2.3)

Momentum at batch statistics is the best result on the board at 77.4%, half a point over plain SGD and 1.7 over AdamW. The first gap is inside the spread of either cell and the second is about at its edge, so the order is momentum, SGD, AdamW on both normalized nets, and only the AdamW gap is a result. AdamW lands under SGD rather than between the two: its per-coordinate second-moment scaling is worth something on the un-normalized net and nothing once BN is doing the conditioning.

Reading down the columns recovers Lever 1, and this time it recovers it everywhere. Batch statistics are ahead in all three — \(+8.0\) under SGD, \(+5.2\) under momentum, \(+2.9\) under AdamW — and the per-image form by \(+5.8\), \(+4.2\) and \(+1.5\). The benefit shrinks as the optimizer gets stronger, which is the familiar reading: a better update rule can partly substitute for better conditioning, and never entirely.

Where the spread is. Read the parenthesised ranges. Without BN, five runs of the same command land 2.7 to 3.3 points apart under every optimizer; at batch statistics, 3.0, 2.2 and 2.3; per image, 1.1, 1.0 and 0.2. So normalization is not what makes a single run’s number mean something. What does is where the evaluation’s statistics come from. The per-image net scores each test image with the statistics it trained on. The batch-statistics net scores on running means and variances, an average over roughly the last hundred steps (bnMomentum \(0.99\)), and at a constant learning rate the weights move faster than that average follows, so the final test read carries the position of the last few hundred steps as well as the seed. The dips in Figure 4-3 are the same effect at the epoch scale. The ImageNet chapters run this layer under a cosine schedule, which is what settles it. The un-normalized AdamW runs also printed loss=NaN in late epochs in three of the five seeds while their accuracy held, and that turned out to be the diagnostic, not the training: the progress line’s loss is \(\sum \mathrm{onehot}\cdot \log (\mathrm{softmax})\) with no log-sum-exp guard, so once an off-class probability underflows to zero it prints \(0\cdot (-\infty )\). The gradient is \(\mathrm{softmax}-\mathrm{onehot}\) and never takes the log; the next epoch’s loss came back finite and the accuracy never moved.

The reason we can make this comparison and trust it is that the optimizer is one swappable rendered tail. The forward pass, the softmax–cross-entropy loss, the backward pass, and every parameter gradient are the same proof-rendered graph in each column. Only the final per-parameter update op changes:

  • SGD: \(\theta \leftarrow \theta - \mathrm{lr}\cdot g\).

  • Momentum (Nesterov): \(v \leftarrow \mu v + g\), then \(\theta \leftarrow \theta - \mathrm{lr}\, (\mu v + g)\).

  • AdamW: the bias-corrected first/second-moment step, rendered op-for-op as Proofs.adamWParam (Theorem 63).

Each tail is emitted onto the same certified gradient (sgdParamF, momParamF with momVNextF, adamWParamF), so the ablation is honest in the strong sense: identical, machine-checked gradients with different arithmetic stacked on top. And for plain SGD, that the binary32 step actually decreases the loss is itself a proved theorem.

The proofs say the emitted graph computes the gradient this chapter derives, and they say it in exact real arithmetic. They say nothing about whether forty epochs of binary32 updates stay in range, and nothing at all about the progress line: the scalar loss it prints is a report-only op outside the proven graph, which is exactly where the NaN above came from. A verified gradient is not a guarantee of numerical stability, and the low-precision seed that collapses to chance in the next section is what that distinction looks like when it bites.

One caution about what this table measures. An ablation measures the thing you varied only if everything else is held constant, and “everything else” includes the data pipeline, the learning-rate schedule and the op family the graph is rendered through. All three live outside the network, none of them is visible to the proofs, and each of them is capable of moving these numbers by more than the lever does. The gradients being machine-checked does not make the experiment design correct.

Lever 3: the arithmetic

The obvious third lever is the number format. Weights and activations do not have to be float32: bfloat16 keeps float32’s exponent range and throws away sixteen bits of mantissa, so it holds roughly three significant decimal digits instead of seven while overflowing at the same place. Modern GPUs multiply it about twice as fast and store it in half the memory, which is why the ImageNet-scale runs from Chapter 5 onward are trained that way. (The Imagenette runs those chapters lead with stay in float32; bf16 is what the full-ImageNet jobs need, and the tables there say so per row.) The question this chapter would like to answer is whether that costs any accuracy.

The renderer has two convolution op families — the per-example one the MNIST chapters use, and the batched one the ImageNet chapters use — and the bf16 twins exist in the batched family only. The batch-statistics net lives there already, since a statistic over the batch needs the batch axis in the graph, so its bf16 arms come with the family. BatchNorm itself stays float32, which is not a shortcut but the recipe every ImageNet network in this book uses — low precision through the convolutions and the dense layers, full precision through the normalization.

So: the eight-convolution net with BatchNorm, one renderer, one constant rate, forty epochs, five seeds, and precision the only thing that differs between a pair of rows. Every convolution op in the graph is bf16 in the low-precision arms — twenty-three of them: eight forward, eight weight gradients, and the seven input gradients (the first layer’s input is the image and needs none). Table 4-2 is the six arms, five seeds each.

Table 4-2 Final CIFAR-10 test accuracy per seed in fp32 and bf16, for each optimizer, the net at batch statistics (runs/2026-10-24-bbn-seeds/).
   

final test accuracy, per seed

median

SGD (lr 0.1)

fp32

77.89

74.93

76.78

76.71

77.18

76.78

 

bf16

77.41

76.38

78.08

76.57

76.29

76.57

momentum

fp32

77.91

77.71

77.36

76.21

75.73

77.36

 

bf16

77.93

78.22

77.76

77.76

78.37

77.93

AdamW

fp32

75.54

75.69

75.00

76.24

77.25

75.69

 

bf16

77.47

72.22

73.34

76.71

76.72

76.71

Medians move by \(-0.21\), \(+0.57\) and \(+1.02\). Each of those is smaller than the spread within the corresponding fp32 row — \(2.96\), \(2.18\) and \(2.25\) — so on this net, at this scale, the number format is not measurable against the noise of rerunning the same configuration. Thirty arm-runs, no NaN, nothing collapsed to chance. The two low bf16 AdamW seeds at \(72\) and \(73\) make that the widest row on the board, and the bf16 momentum row, five seeds within \(0.6\) of each other, is the narrowest.

The un-normalized net answers differently, and the difference is the interesting part. Run the same comparison there (the fp32b and bf16 arms of runs/2026-09-01-cifar8w-6arm-constlr/) and the medians move by \(-1.40\), \(-0.56\) and \(-0.09\), and one bf16 momentum seed collapses to chance where no fp32 seed does. Normalization is doing more than accelerating training: it is absorbing the perturbation. A net that was already the less repeatable one in fp32 — the three-point ranges of the previous section — is the one where sixteen fewer mantissa bits show up at all.

The same pattern holds for a lever we did not mean to pull. Changing the op family at fixed fp32 — per-example to batched, the same arithmetic throughout — moves the un-normalized net by \(+2.0\) on SGD and \(+1.7\) on momentum, which is larger than anything precision does to it. On the per-image net the identical change is worth \(-0.11\), \(+0.37\) and \(+0.19\). The rendering path is a lever, it is invisible to the proofs, and normalization damps it too.

An fp8 (E4M3) arm exists in the repository and is left out of this table on purpose: its render rounds the forward convolutions only and leaves the backward in float32, so it is a lowering probe rather than a trainable arm, and reporting it beside bf16 would imply a comparison that was never run.

Does the head width matter?

Almost not at all, which is exactly why we could borrow MNIST’s head untouched. The 2\(\times \)512 head carries about \(334{,}000\) of the net’s \(374{,}000\) parameters. Swap it for a narrow 64-wide head (\(13{,}000\) params, whole net down to \(53{,}000\)) and, under AdamW on the sweep in Figure 4-4, the two land a point and a half apart with overlapping intervals: \(76.40\% \) narrow, \(77.85\% \) wide. Seven times the parameters buys no accuracy the test set can resolve. The eight convolutions over the \(32 \times 32\) maps dominate the compute, and the head is cheap matmul at these widths. That is the lesson the bridge makes concrete: at this scale the depth of the convolutional body is the lever, not the width of the classifier on top. It is why the head can stay MNIST’s, and why the next chapter spends its budget on thirty-four layers of more convolution rather than a bigger head.

That comparison is worth drawing out in full, because the parametric renderer makes it cheap. The grid driver, cifar8wb-bbn-grid, holds the eight-convolution backbone fixed and renders the AdamW train step, the forward and the evaluation forward at any head width \(d\) (the same \(D_1\) that was hard-wired to 64 is now a parameter of the verified emitter), so we can sweep \(d\) from 8 to 4096 and train each point on its own proof-rendered StableHLO. Dealt across three of the box’s four cards, the whole curve, Figure 4-4, is a four-minute run.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.2cm,
    xlabel={Dense-head width $d$ (both head layers; backbone fixed)},
    ylabel={CIFAR-10 test accuracy (\%)},
    xmode=log, log basis x=2,
    xmin=6.5, xmax=5000, ymin=74.5, ymax=79.5,
    xtick={8,16,32,64,128,256,512,1024,2048,4096},
    xticklabels={8,16,32,64,128,256,512,1024,2048,4096},
    ytick={75,76,77,78,79},
    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=1.5pt},
]
\addplot[blue, mark=*, mark options={fill=blue},
  error bars/.cd, y dir=both, y explicit,
  error bar style={blue!55!white, line width=0.6pt},
  error mark options={rotate=90, mark size=2.5pt, blue!55!white, line width=0.6pt}]
coordinates {
(8,75.98) += (0,0.83) -= (0,0.85) (16,76.99) += (0,0.81) -= (0,0.84) (32,77.35) += (0,0.81) -= (0,0.83) (64,76.40) += (0,0.82) -= (0,0.84) (128,76.49) += (0,0.82) -= (0,0.84) (256,77.60) += (0,0.81) -= (0,0.83) (512,77.85) += (0,0.80) -= (0,0.82) (1024,77.84) += (0,0.80) -= (0,0.82) (2048,77.30) += (0,0.81) -= (0,0.83) (4096,77.62) += (0,0.81) -= (0,0.83)
};
\addplot[only marks, mark=o, mark size=4pt, red, line width=1pt, forget plot] coordinates {(64,76.40)};
\node[anchor=west, font=\footnotesize, red!70!black] at (axis cs:74,75.2)
  {canonical head $d{=}64$};
\end{axis}
\end{tikzpicture}
Figure 4-4 CIFAR-10 test accuracy against dense-head width for the 8-conv net with BatchNorm at batch statistics, the convolutional backbone held at \([16,16,32,32]\): AdamW, 25 epochs under a cosine schedule with a three-epoch warmup (runs/2026-10-24-cifar8wb-bbn-grid-xla/). Each point is one run; the whiskers are the 95% Wilson interval on the \(10{,}000\)-image test set.

The recipe is the grid driver’s own; the levers above ran at a constant rate. A whisker is about \(\pm 0.8\) here, and is not a spread over repeats. Read that way the curve makes a sharper claim than “essentially flat”: every width from \(d{=}16\) to \(d{=}4096\) spans \(76.4\) to \(77.9\% \), about one point’s own interval, so all nine of those intervals overlap the best point’s and none of the differences between them is resolvable from this experiment. The \(128\times \) wider head buys nothing measurable. One difference does survive: \(d{=}8\) (\(76.0\% \), interval \(75.1\)–\(76.8\)) stops short of the best point’s interval, which starts at \(77.0\); there the \(128{\to }8\) first layer throttles the 128-dim feature map the convolutions produce. The canonical \(d{=}64\) (circled) sits on the plateau: the head width genuinely does not matter here, which is the whole reason the net could borrow MNIST’s classifier untouched. (The \(d{=}512\) point is the board’s own net, and lands two points over its 40-epoch AdamW cell in Table 4-1; that is the cosine schedule against the constant rate, not the width, and the shape is the point.)

Why the levers work

Both levers do the same underlying thing by attacking different sources of noise. They make each step’s gradient a more reliable guide to the next. Each layer is tuned for the distribution of its inputs, but those inputs are the outputs of every layer below, which shift every step, so each layer chases a moving target, and the step that helps one layer can wreck the next. BN removes the moving part by pinning every layer’s input to mean-zero, unit-variance before the learnable \(\gamma ,\beta \) get a say. Ioffe & Szegedy framed this as reducing “internal covariate shift,” while Santurkar et al. (2018) argued the sharper effect is a smoother loss landscape. Momentum attacks a different noise: by averaging successive gradients it cancels the mini-batch jitter and accumulates the consistent direction. Both make the per-step direction more trustworthy, and reliable progress compounds across epochs, which is what the curves and the table measure.

The two levers interact, and the momentum column is where that shows. Momentum makes each step longer and more consistent, which is why it finishes first, and a longer step through eight unnormalized layers is also what let one low-precision momentum run collapse to chance. Normalization is what makes the aggressive optimizer survivable. That is the standard account of why the two arrived together, and why every image architecture since 2015 bakes a normalization layer in by default. At larger depth the effect is stronger still, and it is what opens up the learning rates that diverge without it. Between them the two levers decide not only how many passes the net needs, but whether it finishes them at all.

4.4 MLIR: BatchNorm

What is already proven. BatchNorm factors as \(\mathrm{bnForward} = \mathrm{bnAffine} \circ \mathrm{bnNormalize}\), and its reverse-mode derivative is the three-term formula of Theorem 48: with \(\hat{x} = (x-\mu )\, \mathrm{istd}\),

\[ dx = \frac{\mathrm{istd}\, \gamma }{n}\Bigl(n\, dy \; -\; \textstyle \sum _k dy_k \; -\; \hat{x}\, \textstyle \sum _k \hat{x}_k\, dy_k\Bigr). \]

bnHasVJP proves it, composing bnNormalizeHasVJP (the rank-1 wringer) with bnAffineHasVJP (the \(\gamma \, dy\) half). The one subtlety, isolated in Theorem 46, is that the inverse-stddev term carries an \(\mathrm{istd}^3\) that needs \(\varepsilon {\gt} 0\) to stay differentiable. That is the first place in the book where the math reaches past chain-sum-product into real analysis.

The gap and how we close it. The three-term backward is not elementwise, because the two \(\sum _k\) reductions couple every coordinate in a group to every other. The emitted graph is given a denotation in the proofs’ own vector type, and bn_back_bridge proves that denotation equal to bnHasVJP’s backward: the reduce/broadcast/elementwise graph is, by machine check, the three-term formula. Here it is in the chapter’s trained artifact, verified_mlir/cifar8w_bn_sgd_train_step.mlir, at the eighth convolution’s BatchNorm (32 channels at \(4\times 4\), batch 128), with the recompute of the forward statistics elided down to the rsqrt (SSA names the artifact’s, lines wrapped):

%v289 = stablehlo.reshape %v288 : (tensor<128x512xf32>)
      -> tensor<128x32x4x4xf32>
%v290 = stablehlo.reshape %v219 : (tensor<128x512xf32>)
      -> tensor<128x32x4x4xf32>
%v292 = stablehlo.constant dense<16.0> : tensor<128x32x4x4xf32>
// ...
%v303 = stablehlo.rsqrt %v302 : tensor<128x32x4x4xf32>
%v304 = stablehlo.multiply %v297, %v303 : tensor<128x32x4x4xf32>
%v305 = stablehlo.broadcast_in_dim %g8, dims = [1] : (tensor<32xf32>)
      -> tensor<128x32x4x4xf32>
%v306 = stablehlo.multiply %v305, %v289 : tensor<128x32x4x4xf32>
%v307 = stablehlo.reduce(%v306 init: %v291)
      applies stablehlo.add across dimensions = [2, 3]
      : (tensor<128x32x4x4xf32>, tensor<f32>) -> tensor<128x32xf32>
%v308 = stablehlo.broadcast_in_dim %v307, dims = [0, 1]
      : (tensor<128x32xf32>) -> tensor<128x32x4x4xf32>
%v309 = stablehlo.multiply %v304, %v306 : tensor<128x32x4x4xf32>
%v310 = stablehlo.reduce(%v309 init: %v291)
      applies stablehlo.add across dimensions = [2, 3]
      : (tensor<128x32x4x4xf32>, tensor<f32>) -> tensor<128x32xf32>
%v311 = stablehlo.broadcast_in_dim %v310, dims = [0, 1]
      : (tensor<128x32xf32>) -> tensor<128x32x4x4xf32>
%v312 = stablehlo.multiply %v306, %v292 : tensor<128x32x4x4xf32>
%v313 = stablehlo.subtract %v312, %v308 : tensor<128x32x4x4xf32>
%v314 = stablehlo.multiply %v304, %v311 : tensor<128x32x4x4xf32>
%v315 = stablehlo.subtract %v313, %v314 : tensor<128x32x4x4xf32>
%v316 = stablehlo.divide %v303, %v292 : tensor<128x32x4x4xf32>
%v317 = stablehlo.multiply %v316, %v315 : tensor<128x32x4x4xf32>

Read it against the formula. %v289 is the incoming cotangent \(dy\) and %v290 the layer’s input \(x\); %v303 is \(\mathrm{istd}\) and %v304 is \(\hat{x}\), recomputed from \(x\) rather than saved. %v306 is the affine backward \(\gamma \, dy\), with \(\gamma \) (%g8) broadcast along the channel axis. Each reduce across dimensions = [2, 3] followed by a broadcast_in_dim is one of the cross-coordinate sums: \(\sum _k \gamma \, dy_k\) as %v308 and \(\sum _k \hat{x}_k\, \gamma \, dy_k\) as %v311. %v312 is \(n\, \gamma \, dy\) with \(n = 16\) (%v292), %v313 subtracts the centering correction, %v315 subtracts the rank-1 normalization correction \(\hat{x}\sum \hat{x}\, \gamma \, dy\), and %v317 scales the bracket by \(\mathrm{istd}/n\) (%v316). The graph folds \(\gamma \) into %v306 up front, which is why that leading scale is \(\mathrm{istd}/n\) and not \(\mathrm{istd}\, \gamma /n\): the same formula with \(\gamma \) pulled inside the parenthesis.

The reduction axes say which layer this is. [2, 3] are the two spatial axes, so each statistic is taken per image and per channel over the 16 cells of a \(4\times 4\) map, which is the per-image normalization the section “What BN actually is” above describes; a batch-statistics BatchNorm reduces over [0, 2, 3], and that is what the ResNet and MobileNet artifacts of the next chapters emit. Both are instances of the one theorem, over groups of different width. The leaf theorem here is bnPerChannelBack_faithful: the per-channel three-term graph denotes the input gradient of bnPerChannelTensor3, the block-diagonal Jacobian contracted against \(dy\), under \(\varepsilon {\gt} 0\); its batch-statistics twin is bnBatchTensor4GradInput_eq_backward. Theorems 56 and 57 state that every parameter update in this file is the certified step at the cotangent the file’s own backward delivers, the eight BatchNorms included.

Because LayerNorm is BatchNorm along a different axis, the very same emitted graph denotes the LayerNorm backward (layernorm_back_bridge is literally bn_back_bridge). That is the normalization sitting inside the residual, depthwise, and ConvNeXt blocks built on this foundation.

Caveats.

  • Needs \(\varepsilon {\gt} 0\). The inverse-stddev term carries an \(\mathrm{istd}^3\). Given \(\varepsilon {\gt} 0\) the bridge holds at every input, with no smooth-point exclusion, unlike ReLU and max-pool.

  • Shape. The listing is one of the eight BatchNorms of the trained net; the theorem is stated over any group width \(n\).

4.5 Recap

BatchNorm is the hardest proof in the book and the cleanest statement. Its backward is a three-term formula, the direct term, a centering correction and a rank-one normalization correction, and the inverse-standard-deviation inside it is what needs Mathlib’s analysis: given \(\varepsilon {\gt} 0\) the gradient is bounded and the bridge is unconditional, smooth everywhere, with none of the smooth-point exclusions ReLU and max-pool carry. Theorem 50 is that formula; the per-channel and batch-statistics forms are its instantiations; and the eight-convolution step is tied with BatchNorm and without (Theorems 55 and 56), the batch-statistics step node by node and as the loss’s gradient (Theorems 58 and 60), with descent at the last convolution. The same emitted graph is LayerNorm’s backward, since LayerNorm is BatchNorm along a different axis, which is why Chapters 8 and 9 add no normalization proof of their own.

The experiment half says what the proofs do not: at eight convolutions, normalization is what governs training, the optimizer moves the result by less than a seed does, the group the statistics are taken over decides whether one run’s number can be trusted, and the number format is not measurable against seed noise. Eight is also about where a plain stack stops helping. The next chapter adds the skip connection, whose VJP is additive fan-in, the second of the three composition rules, and composes it with convolution and BatchNorm to go thirty-four layers deep, at ImageNet scale, with no new analysis at all.