Verified Deep Learning with Lean 4

7 EfficientNet

A convolution treats every channel equally

By the time a feature map reaches a deep stage of a CNN, each channel is a learned “feature detector” for something specific: a headlight, a curve, a fur texture, a leaf edge. A standard \(3 \times 3\) convolution that consumes this feature map treats every one of those channels as equally important contributors to every output channel. Its kernel learns a fixed mixing recipe that’s applied to every spatial position of every input image, regardless of what’s actually in the image.

That’s wasteful in a particular way. A “headlight” channel is useful when there’s a car in the image and useless on a beach. A “leaf-edge” channel is useful in a tree-heavy region and useless in the sky. The network has no built-in way to say “for this image, dial channel 47 up and channel 112 down.” Hu et al. 2018 (arXiv:1709.01507) designed a tiny module that lets the network do exactly that: look at the global content of the feature map, produce a per-channel gain, and rescale every channel by its gain before passing the feature map forward.

That module is the Squeeze-and-Excitation block, and at its core it is a learned per-channel attention mechanism, contemporary with the transformer’s (Vaswani et al. 2017) and three years ahead of attention’s arrival in vision.

7.1 Run it first

Before the attention mechanism, train the network that uses it. Four commands, and about forty 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 efficientnet-verified-adam
./.lake/build/bin/efficientnet-verified-adam data

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

[pjrt_ffi] XLA backend: PJRT 0.112, 1 device(s)
[pjrt_ffi] compiled verified_mlir/efficientnet_adam_train_step.mlir
             (@efficientnet_adam_train_step, 740 outputs, 1 replica)
             in 10603 ms
[pjrt_ffi] compiled verified_mlir/efficientnet_fwd.mlir
             (@efficientnet_fwd, 1 outputs, 1 replica) in 635 ms
[pjrt_ffi] compiled verified_mlir/efficientnet_fwd_eval.mlir
             (@efficientnet_fwd_eval, 1 outputs, 1 replica) in 454 ms
EfficientNet-B0 on Imagenette 224² (stem-s2 → 16 MBConv [t,c,n,s,k],
  swish + squeeze-excite + batch-norm, 5 downsamples 224→7 →
  head 320→1280 → GAP → dense)
  via the VERIFIED renderer → XLA/PJRT → GPU
  train 9469, val 3925; bs 32, EfficientNet-B0 adam
    (cosine+warmup 3ep, baseLR 0.001000), He init
  running-stats BN: 49 layers, 42016 stat floats
    → eval via @efficientnet_fwd_eval
Epoch 1/80: loss=2.063774 lr=0.000333
  epoch 1: val_acc = 758/3925 = 19.312102%  top5 = 2630/3925 = 67.006369%
Epoch 2/80: loss=1.631765 lr=0.000667
  epoch 2: val_acc = 1216/3925 = 30.980892%  top5 = 2889/3925 = 73.605096%
Epoch 3/80: loss=1.430369 lr=0.001000
  epoch 3: val_acc = 1526/3925 = 38.878981%  top5 = 3173/3925 = 80.840764%
Epoch 4/80: loss=1.294553 lr=0.001000
  epoch 4: val_acc = 2116/3925 = 53.910828%  top5 = 3521/3925 = 89.707006%
Epoch 79/80: loss=0.504706 lr=0.000000
  epoch 79: val_acc = 3535/3925 = 90.063694%  top5 = 3857/3925 = 98.267516%
Epoch 80/80: loss=0.505049 lr=0.000000
  epoch 80: val_acc = 3534/3925 = 90.038217%  top5 = 3858/3925 = 98.292994%
done (trained EfficientNet-B0 adam + cosine/warmup via packed threading).

Eighty epochs at about 30 seconds each, forty-one minutes in total, and 90.04% top-1 with 98.29% top-5 on Imagenette’s 3,925-image validation split. The best epoch reached \(90.17\% \). That is level with ResNet-34’s five-seed \(89.99 \pm 0.32\) and eight tenths of a point over MobileNetV2’s \(89.25\% \), on the same data, the same recipe and the same eighty epochs. EfficientNet-B0 does it with \(5.3\times \) fewer parameters than ResNet-34 and in forty-one minutes against forty-seven.

Three steps, one tensor product

An SE block on a feature map \(x \in \mathbb {R}^{B \times C \times H \times W}\) runs three operations in sequence:

  1. Squeeze. Global-average-pool across spatial dimensions: \(s_c = \frac{1}{HW} \sum _{i,j} x_{c, i, j}\). Result is a \(B \times C\) tensor: one scalar per image per channel, summarizing the channel’s overall activity.

  2. Excite. Push \(s\) through a tiny two-layer MLP with a bottleneck: \(g = \sigma (W_2\, \mathrm{ReLU}(W_1 s))\), where \(W_1\) is \(C \to C/r\) and \(W_2\) is \(C/r \to C\) for a reduction ratio \(r\) (typically 16). The sigmoid squashes each output to \([0, 1]\). Result: \(g \in \mathbb {R}^{B \times C}\), a per-channel gate value.

  3. Apply. Multiply the original feature map by the gates, broadcast across the spatial axes: \(y_{c, i, j} = g_c \cdot x_{c, i, j}\). Channels with gate near 1 pass through, and channels with gate near 0 are suppressed.

That’s it. The MLP operates on a \([B, C]\) tensor, not \([B, C, H, W]\), so it’s tiny, at \(C^2 / r + C/r + C\) parameters per block against millions for a standard conv at the same width.

From the framework: it’s an elementwise product

What is the SE block, as a function? Up to a learned scalar factor per channel, it’s just an elementwise product of the input with a side branch:

\[ \mathrm{se}(x) \; =\; x \; \odot \; g(\mathrm{gap}(x)), \]

where the gate \(g\) is built from the saved input \(x\) via two dense layers and a sigmoid. We already proved the VJP of an elementwise product (Theorem 13). We already proved the VJP of dense (Theorem 19). We already proved the VJP of identity, and an additive fan-in collapses to the same product-rule structure as elementwise. Theorem 96 is one line of composition, and it introduces no new analytic dependencies.

Attention in Disguise

SE looks at the entire feature map, produces a content-dependent re-weighting, and applies it back to the data. That is a learned attention operation in the standard sense. The reduction is over the spatial axes (so each token-equivalent is a channel, not a patch), but the shape is the same: a query (GAP summary) attending over a collection (the channels) to produce weights, weights gating the original signal.

The transformer’s self-attention (Vaswani et al. 2017) generalizes the same pattern from a fixed query (the GAP) to a learned sequence of queries (the token positions) and from a fixed key/value (the channels) to a learned key/value (per-token features). SE is the bare minimum version of that idea. Most post-2018 convolutional architectures carry SE or one of its descendants (CBAM, ECA, GE), because the accuracy-per-parameter cost is low and the operation composes cleanly with anything else.

EfficientNet-B0: MobileNet-V2 with SE everywhere

EfficientNet-B0 (Tan & Le 2019, arXiv:1905.11946) is, at the architectural level, MobileNet-V2 with two changes:

  1. Every MBConv block contains an SE sub-module after the depthwise conv. The verified spec spells that as its own constructor, .mbConvSENB, rather than as a flag.

  2. Swish (\(x \cdot \sigma (x)\)) replaces ReLU as the activation. Smoother gradient, modest empirical lift, and no new VJP structure, just chain rule through a sigmoid.

The paper’s actual thesis is compound scaling: jointly scale depth, width, and input resolution by a single coefficient \(\phi \) and you get the EfficientNet family B0 through B7, with B7 reaching ImageNet state-of-the-art at the time. At the B0 baseline (the one in this book), the architectural lift over MobileNet-V2 is essentially SE plus Swish. The compound scaling story lives in the B0\(\to \)B7 progression, which we don’t fit on Imagenette because the dataset is too small for the return-on-scaling to show up.

7.2 The theorems

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

\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=60.6pt, minimum height=12.7pt] at (172.2pt,113.4pt) {\hyperref[thm:seBlockHasVJP]{seBlockHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=98.4pt, minimum height=12.7pt] at (49.2pt,64.2pt) {\hyperref[thm:efficientnetFullHasVJP]{efficientnetFullHasVJP}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=60.6pt, minimum height=21.5pt] at (96.2pt,10.7pt) {\hyperref[thm:efficientnet_whole_back]{efficientnet\_}\\\hyperref[thm:efficientnet_whole_back]{whole\_back}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=60.6pt, minimum height=21.5pt] at (172.2pt,64.2pt) {\hyperref[thm:efficientnet_step_tie]{efficientnet\_}\\\hyperref[thm:efficientnet_step_tie]{step\_tie}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=60.6pt, minimum height=21.5pt] at (195.2pt,10.7pt) {\hyperref[thm:efficientnet_loss_grad]{efficientnet\_}\\\hyperref[thm:efficientnet_loss_grad]{loss\_grad}};
  \node[draw=green!50!black, fill=green!15, rectangle, rounded corners=4.0pt, align=center, minimum width=60.6pt, minimum height=21.5pt] at (276.2pt,64.2pt) {\hyperref[thm:efficientnet_sync_tie]{efficientnet\_}\\\hyperref[thm:efficientnet_sync_tie]{sync\_tie}};
  \node[draw=gray!70, dashed, rounded corners=2pt, minimum width=75.3pt, minimum height=17.8pt, text=gray!60!black, align=center, font=\fontsize{5.50}{6.60}\selectfont\ttfamily] at (172.2pt,160.6pt) {\hyperref[thm:elemwiseProductHasVJP]{elemwiseProductHasVJP}\\(ch 1)};
  \draw[->, >=stealth, black!70, line width=0.40pt] (156.9pt,106.5pt) .. controls (136.3pt,98.6pt) and (99.6pt,84.5pt) .. (74.8pt,75.0pt) -- (64.3pt,71.0pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (165.7pt,106.7pt) .. controls (158.0pt,99.7pt) and (144.8pt,87.2pt) .. (135.2pt,75.0pt) .. controls (124.4pt,61.2pt) and (114.0pt,44.2pt) .. (106.7pt,31.3pt) -- (101.4pt,21.6pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (172.2pt,106.6pt) .. controls (172.2pt,101.6pt) and (172.2pt,94.1pt) .. (172.2pt,86.9pt) -- (172.2pt,75.4pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (180.9pt,106.7pt) .. controls (189.9pt,100.3pt) and (203.5pt,88.8pt) .. (209.2pt,75.0pt) .. controls (214.8pt,61.3pt) and (211.0pt,45.0pt) .. (205.9pt,32.3pt) -- (201.1pt,21.9pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (185.0pt,106.6pt) .. controls (199.4pt,100.0pt) and (223.6pt,89.1pt) .. (243.4pt,80.1pt) -- (253.9pt,75.4pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (54.2pt,57.7pt) .. controls (60.3pt,51.1pt) and (70.6pt,39.8pt) .. (79.6pt,30.0pt) -- (87.2pt,21.6pt);
  \draw[->, >=stealth, black!70, line width=0.40pt] (176.7pt,53.1pt) .. controls (179.4pt,47.1pt) and (182.8pt,39.5pt) .. (186.0pt,32.3pt) -- (190.7pt,21.9pt);
  \draw[->, >=stealth, dashed, gray!60, line width=0.40pt] (172.2pt,151.3pt) .. controls (172.2pt,145.8pt) and (172.2pt,138.3pt) .. (172.2pt,131.5pt) -- (172.2pt,120.0pt);
\end{tikzpicture}
Figure 7-1 Dependency graph of the squeeze-and-excitation chapter’s theorems.
Theorem 96 SE block VJP
✓
#

assume:

  1. \(B_g\) is a correct backward function for the gate (\(\mathsf{HasVJP}\, \mathrm{gate}\)) [hg]

  2. \(\mathrm{gate}\) is differentiable everywhere [hg_diff]

prove: \(\mathsf{HasVJP}\, (\mathrm{seBlock}\, \mathrm{gate})\), where \(\mathrm{seBlock}\, \mathrm{gate}\, x = x \odot \mathrm{gate}(x)\), with backward

\[ B(x, dy) = \mathrm{gate}(x) \odot dy + B_g\bigl(x,\; x \odot dy\bigr). \]

SE multiplies input by a sigmoid-gated channel mask.

Proof ▶
  1. \(\mathrm{seBlock}\, \mathrm{gate} = \mathrm{elemwiseProduct}\, \mathrm{id}\, \mathrm{gate}\).
    proof: Definitional.

  2. q.e.d.
    proof: Instantiate the multiplicative fan-in VJP (Theorem 13) at \(f = \mathrm{id}\) (differentiable; its VJP is Theorem 14), with assumptions 1 and 2 supplying the gate side. The general backward \(B_f(x, g(x) \odot dy) + B_g(x, f(x) \odot dy)\) specializes: the identity’s backward passes \(\mathrm{gate}(x) \odot dy\) through unchanged (main path, each channel scaled by its gate), and the gate sub-network sees \(x \odot dy\) as its cotangent — not bare \(dy\).

The ladder for EfficientNet-B0: the VJP, the chain, the step, the loss gradient and the data-parallel tie; no seal.

assume:

  1. every BatchNorm’s \(\varepsilon {\gt} 0\), forty-nine of them [h\(\varepsilon \)w]

prove: the backward composed from the per-block backwards, for the batched EfficientNet-B0 — sixteen MBConv blocks with squeeze-and-excitation and swish, at training BatchNorm and batch \(N\) (efficientnetForwardBFull) — equals the \(\operatorname {pdiv}\)-contracted Jacobian of the forward at every input, cotangent and pixel; and the emitted forward graph denotes exactly that forward. With stochastic depth on the nine blocks that carry a skip and classifier dropout between the pool and the dense, the drop and dropdo forward graphs denote the masked forward at every mask, at training and at inference BatchNorm, at either precision — the committed efficientnetin_drop_fwd and efficientnetin_dropdo_fwd are the f32 instances. The graph ties take no hypothesis, and the VJP no smooth-point one: swish and the SE gate have no corner.

Proof ▶

A fold of the VJP chain rule (Theorem 11) over the stem, the sixteen blocks (Theorems 64 and 96) and the head; the graph ties op by op; see Proofs.efficientnetForwardBFullHasVJP_correct.

assume:

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

prove: the hand-written sixteen-block reverse chain efficientnetInputGradBFull equals

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

for every batch, cotangent and pixel.

Proof ▶

The chain is the composed backward of Theorem 97 read stage by stage; see Proofs.efficientnetInputGradBFull_correct.

assume:

  1. every BatchNorm’s \(\varepsilon {\gt} 0\), all forty-nine of them [h\(\varepsilon \)w]

prove: each of the 213 emitted parameter gradients of the batched EfficientNet-B0 step (efficientnet_adam_train_step.mlir and the efficientnetin_* steps) denotes the certified batch gradient at the chain cotangent of the label-smoothed cross-entropy — through swish, the SE gate’s fan-in and the batch-BatchNorm backward — with the target and the number of classes binders. Stated at one replica, at either precision, with stochastic depth on the nine skip-carrying blocks and the classifier dropout as optional masks: at none the chain is the drop-free artifacts’, at some the drop, do and dropdo renders’ — efficientnetin_emarmstfdp64dropdowneps0001bf16, the conf’s, among them — where a masked block’s nodes read the cotangent through its site and the skip reads it raw, and the dense weight node reads the dropped activation; the optimizer tails that consume the nodes are outside it. The tie also carries the forty-nine convolution-bias nodes no render emits.

Proof ▶

A fold over the 213 nodes, the chain’s cotangents the certified block backwards; see Proofs.EnetTieG.efficientnet_net_tiedG.

assume:

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

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

prove: for each node of Theorem 99, the node at the chain cotangent from \(g\) is \(\partial L/\partial \theta \) of the forward at the same masks (efficientnetForwardBFullDrop; efficientnetForwardBFull at none) with that one parameter varied, at any batch and number of classes. Instantiated at the label-smoothed cross-entropy.

Proof ▶

The step tie’s node, with \(g\) pulled back through the certified block backwards (Theorem 16, once per stage); see Proofs.EnetTieG.enet_net_lossGrad.

assume:

  1. \(R \ge 1\) replicas of \(N \ge 1\) images each, all forty-nine BatchNorms synchronised with \(\varepsilon {\gt} 0\) [hR, hN, h\(\varepsilon \)w]

  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 213 all-reduced gradient nodes of the data-parallel renders at either precision (efficientnetin_emarmstfdp64dropdowneps0001bf16, the conf’s, among them, at \(\texttt{replicas} {\gt} 1\); loss \(\div B\) per replica; each replica’s stochastic-depth and dropout masks the shards of global ones) equals the single-device node of Theorem 99 at batch \(R \cdot N\) and those global masks, loss \(\div (R \cdot B)\). Instantiated at the label-smoothed cross-entropy. The forward half — each replica’s shard of the data-parallel forward graph denotes the single-device forward at its shard of the masks — is its own statement.

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.EnetSyncTieG.efficientnet_net_syncTiedG.

7.3 Example: EfficientNet-B0 on Imagenette

The architecture

EfficientNet-B0 is the MobileNetV2 column with Squeeze-and-Excitation in every block: the same \(3 \times 3\) stride-2 stem, seven stages of MBConv blocks (kernel sizes mixing \(3 \times 3\) and \(5 \times 5\) depthwise), the \(1 \times 1\) lift to 1280, global average pool, and dense head. Figure 7-2 is the network, and its inset shows one MBConv block, an inverted residual with an SE gate (highlighted) inserted before the project.

\begin{tikzpicture} [
  >={Stealth[length=1.8mm]},
  every node/.style={font=\sffamily\scriptsize},
  col/.style    = {align=center, rounded corners=2pt, inner sep=2pt, minimum height=0.56cm, minimum width=6.7cm},
  io/.style     = {col, draw=blue!55!black,    fill=blue!8},
  convbn/.style = {col, draw=orange!65!black,  fill=orange!12},
  blk/.style    = {col, draw=magenta!70!black, fill=magenta!9},
  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.16cm of input] (stem) {\textbf{ConvBN} stem $3\to32$, $3\times3$, /2 \;\; $112{\times}112$};
  \node[blk, below=0.16cm of stem]     (m1)   {$1\times$ \textbf{MBConv} $32\to16$, $t1,k3$ \;\; $112{\times}112$};
  \node[blk, below=0.16cm of m1]       (m2)   {$2\times$ \textbf{MBConv} $16\to24$, $t6,k3$, /2 \;\; $56{\times}56$};
  \node[blk, below=0.16cm of m2]       (m3)   {$2\times$ \textbf{MBConv} $24\to40$, $t6,k5$, /2 \;\; $28{\times}28$};
  \node[blk, below=0.16cm of m3]       (m4)   {$3\times$ \textbf{MBConv} $40\to80$, $t6,k3$, /2 \;\; $14{\times}14$};
  \node[blk, below=0.16cm of m4]       (m5)   {$3\times$ \textbf{MBConv} $80\to112$, $t6,k5$ \;\; $14{\times}14$};
  \node[blk, below=0.16cm of m5]       (m6)   {$4\times$ \textbf{MBConv} $112\to192$, $t6,k5$, /2 \;\; $7{\times}7$};
  \node[blk, below=0.16cm of m6]       (m7)   {$1\times$ \textbf{MBConv} $192\to320$, $t6,k3$ \;\; $7{\times}7$};
  \node[convbn, below=0.16cm of m7]    (pw)   {\textbf{ConvBN} $1\times1$ $320\to1280$ \;\; $7{\times}7$};
  \node[gap, below=0.16cm of pw]       (g)    {global avg pool \;\; $7\times7\times1280 \to 1280$};
  \node[head, below=0.16cm of g]       (d)    {\textbf{Dense} $1280\to10$ \;(identity)};
  \node[logits, below=0.16cm of d]     (out)  {Logits \;\; 10 classes, softmax-CE};
  \foreach \a/\b in {input/stem,stem/m1,m1/m2,m2/m3,m3/m4,m4/m5,m5/m6,m6/m7,m7/pw,pw/g,g/d,d/out}\draw[arr](\a)--(\b);
  \foreach \n/\k in {m1/1,m2/2,m3/3,m4/4,m5/5,m6/6,m7/7}\node[stage] at ($(\n.east)+(0.30,0)$){stage \k};
\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.6cm, draw=magenta!70!black, fill=magenta!9},
  se/.style  ={align=center, rounded corners=2pt, inner sep=3pt, minimum height=0.6cm, draw=orange!70!black, fill=orange!12},
  dot/.style ={circle, draw=magenta!70!black, fill=magenta!14, 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, magenta!70!black, shorten >=1pt},
]
  \node[term] (x){$x$};
  \node[box, right=0.45cm of x]   (e){$1\times1$ expand\\$C\!\to\!tC$, BN, swish};
  \node[box, right=0.4cm of e]    (dw){$k\times k$ DW\\BN, swish};
  \node[se, right=0.4cm of dw]    (s){SE gate\\$\times g$};
  \node[box, right=0.4cm of s]    (p){$1\times1$ project\\BN (linear)};
  \node[dot, right=0.45cm of p]   (sum){$+$};
  \node[term, right=0.45cm of sum] (y){$y$};
  \foreach \a/\b in {x/e,e/dw,dw/s,s/p,p/sum,sum/y}\draw[arr](\a)--(\b);
  \draw[skip] (x.north) .. controls +(0,0.9) and +(0,0.9) .. (sum.north)
        node[midway, above, term, magenta!70!black]{skip if stride 1, $C_{\text{in}}{=}C_{\text{out}}$};
  \node[term, below=0.16cm of s, gray!55!black]{one MBConv block: inverted residual $+$ squeeze-excitation};
\end{tikzpicture}
Figure 7-2 EfficientNet-B0: the MobileNetV2 column built from MBConv blocks with squeeze-and-excitation. The inset is one MBConv block.

The verified trainer

The pattern is §5-2’s; only the layers list and the bnChannels that follows from it are new:

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

-- 1. The network. `slug` names the committed render, `bnChannels`
--    drives BN threading, `NB` means the BN-followed convs carry
--    no bias.
def efficientnetVerified : VerifiedNetSpec where
  name     := "EfficientNet-B0"
  slug     := "efficientnet"
  inC      := 3
  imageH   := 224
  imageW   := 224
  data     := .imagenette
  layers   := [
    .convBnNB 3 32 3 2,             -- stem 3x3-s2   224->112
    .mbConvSENB  32   32  16  8 3,  -- stage 1, t=1 (no expand)
    .mbConvSENB  16   96  24  4 3,  -- stage 2       112->56
    .mbConvSENB  24  144  24  6 3,
    .mbConvSENB  24  144  40  6 5,  -- stage 3        56->28
    .mbConvSENB  40  240  40 10 5,
    .mbConvSENB  40  240  80 10 3,  -- stage 4        28->14
    .mbConvSENB  80  480  80 20 3,
    .mbConvSENB  80  480  80 20 3,
    .mbConvSENB  80  480 112 20 5,  -- stage 5           @14
    .mbConvSENB 112  672 112 28 5,
    .mbConvSENB 112  672 112 28 5,
    .mbConvSENB 112  672 192 28 5,  -- stage 6        14->7
    .mbConvSENB 192 1152 192 48 5,
    .mbConvSENB 192 1152 192 48 5,
    .mbConvSENB 192 1152 192 48 5,
    .mbConvSENB 192 1152 320 48 3,  -- stage 7            @7
    .convBnNB 320 1280 1 1,         -- head, 1x1 to 1280
    .globalAvgPool,
    .dense 1280 10 ]
  bnChannels := #[32, 32,16, 96,96,24, ... ]  -- all 49, in order

-- 2. The schedule. ResNet-34's and MobileNetV2's, unchanged.
def efficientnetAdamConfig : VerifiedConfig where
  epochs    := 80
  batchSize := 32

-- 3. The entry point: AdamW, cosine with a 3-epoch warmup, baseLR 1e-3.
--    The rms variant, the EMA decay and the drop rate are env knobs.
def runEfficientNetAdam (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
              efficientnetAdamConfig.batchSize
  let sched := enetRmsSchedule
  let rms := variant.contains "rms"
  let baseLR := match (← IO.getEnv "LEAN_MLIR_BASE_LR_U").bind (·.toNat?) with
    | some u => u.toFloat * 1e-6
    | none   => if rms then sched.lr * bs.toFloat / 256.0 else 0.001
  let emaDecay := match (← IO.getEnv "LEAN_MLIR_EMA_DECAY_U").bind (·.toNat?) with
    | some u => u.toFloat * 1e-6
    | none   => 0.9999
  let dropNet := match (← IO.getEnv "LEAN_MLIR_DROP_RATE_U").bind (·.toNat?) with
    | some u => { efficientnetVerified.toNet with
                  dropKeeps := efficientnetVerified.dropKeepsAt (u.toFloat * 1e-6) }
    | none   => efficientnetVerified.toNet
  dropNet.trainAdamSched { efficientnetAdamConfig with batchSize := bs }
    (argv.head?.getD "data") baseLR 0.9 0.999 3 variant
    (if rms then sched.decayRate else 0.0) sched.decayEpochs emaDecay
def main (argv : List String) : IO Unit := runEfficientNetAdam argv

.mbConvSENB ic mid oc r k is one block per line, as Chapter 6’s constructor was, with mid the expanded width; k is the depthwise kernel, mixing \(3 \times 3\) and \(5 \times 5\) by stage, and the expand \(1 \times 1\) is skipped exactly when mid == ic, which is stage 1 and its \(t=1\).

r is the squeeze width, and it is sized off ic. Every block in the listing has r == ic/4: 32 gives 8, 16 gives 4, 192 gives 48. That is the correctness note this chapter returns to in §7.5 from the other direction, and it is worth reading here first, because sizing the squeeze off the \(6\times \)-larger mid instead is the difference between B0’s canonical 5.29M parameters and an inflated 8.4M. The spec is where that choice is made once and visibly.

The arm, and where the SE gate lives in it.

  | mbConvSENB ic mid oc r k =>                -- Ch. 7
    (if mid != ic then #[(#[mid,ic,1,1],0),(#[mid],1),(#[mid],2)] else #[])
    ++ #[(#[mid,1,k,k],0),(#[mid],1),(#[mid],2),     -- depthwise kxk + BN
         (#[mid,r],0),(#[r],2),(#[r,mid],0),(#[mid],2),   -- SE: squeeze, excite
         (#[oc,mid,1,1],0),(#[oc],1),(#[oc],2)]     -- project 1x1 + BN

Same mid != ic conditional as Chapter 6’s, with four tensors inserted in the middle: [mid,r] down to the squeeze width and [r,mid] back up, each with a bias. Those two are the only convs in the block that keep their biases, because they are not BN-followed — the gate ends in a sigmoid, not a normalizer. That asymmetry is visible in the arm and nowhere else in the spec.

There is no stride argument, and that is deliberate. B0’s downsampling pattern is fixed by the architecture rather than chosen per block, so it lives in the renderer, which emits b1 as no-expand, b2, b4, b6 and b12 as strided, and the remaining nine as residual. The spec’s job is the parameter layout, and efficientnetVerified.toSpecs is kernel-#guarded against an independently audited hand-list.

The BatchNorm list has 49 entries, not the 50 a per-block count gives. Every MBConv normalizes after its expand, depthwise and project convs, which would be \(16 \times 3 + 2\) for the stem and head, or 50. Stage 1 has \(t=1\) and therefore no expand conv, so it contributes two entries rather than three, and the real count is 49. bnChannels exists to get that right: the running-stat region is positional, and an off-by-one misaligns every frozen statistic downstream of it at evaluation time.

The squeeze-excite biases stay. NB drops the bias from every convolution a BatchNorm follows, because the BatchNorm’s own shift absorbs it. SE’s two \(1 \times 1\)s are followed by a sigmoid gate rather than a BatchNorm, so nothing absorbs theirs and they are kept. The rule is about what comes after a convolution, not about the convolution.

Every SE block multiplies the input by the sigmoid output of its two-layer MLP: that is exactly Theorem 96 composed with Theorem 13 and the dense VJP (Theorem 19). No new math in this chapter beyond the SE block itself.

Results

§7.1 has the run: 80 epochs at about 30 seconds each, forty-one minutes, \(\mathbf{90.04\% }\) top-1 and \(\mathbf{98.29\% }\) top-5, with the loss settling on the same \({\sim }0.50\) label-smoothing floor as ResNet-34 and MobileNetV2. Table 7-1 is the three-chapter comparison — same dataset, same recipe, same eighty epochs, every row a measurement on the verified XLA path, on one RTX 4060 Ti.

Table 7-1 ResNet-34, MobileNetV2 and EfficientNet-B0 on Imagenette: 80 epochs each on the verified path, on one card. Step time is the epoch wall-clock divided by 295 batches, validation pass included; MLIR sizes are the committed <slug>_adam_train_step.mlir in 1000-byte units.

Model

Params

MLIR

Step time

Total

Top-1

Top-5

ResNet-34

21.29M

754 KB

120 ms

47 min

89.99%

98.46%

MobileNetV2

2.24M

1,071 KB

90 ms

35 min

89.25%

98.68%

EfficientNet-B0

4.02M

1,317 KB

103 ms

41 min

90.04%

98.29%

ResNet-34’s accuracies are the five-seed means of §5.5.2 (runs/2026-09-12-r34-ablation-fp32-seeds/) and its times are one of those seeds’ (§5.1); the other two rows are one run each.

  • EfficientNet-B0 is level with ResNet-34 on top-1, at \(90.04\% \) against \(89.99 \pm 0.32\), and eight tenths of a point over MobileNetV2’s \(89.25\% \). It gets there with \(5.3\times \) fewer parameters than ResNet-34 and in six fewer minutes. MobileNetV2 keeps top-5 by four tenths, which is the one place the larger model does not win.

  • SE and Swish buy \(+0.71\) top-1 over MobileNetV2 for \(1.80\times \) the parameters (4.02M against 2.24M). That is a real return rather than a bargain: the cost is the two-layer MLP inside every one of the sixteen MBConv blocks. Whether \(1.8\times \) the parameters is worth seven tenths of a point is a judgement, but it is a much better trade than it looks on the ImageNet leaderboard, where B0 is usually compared against far larger models.

  • 1,317 KB of MLIR is the largest render in the book, ahead of MobileNetV2’s 1,071 KB and ResNet-34’s 754 KB. The extra ops are SE’s: each block emits a global-average-pool, two denses, a sigmoid and an elementwise multiply, and then the product-rule backward for all of it. Sixteen blocks of that is a lot of StableHLO.

  • 103 ms per step sits between the two, slower than MobileNetV2’s 90 ms because of exactly those extra ops, and quicker than ResNet-34’s 120 ms by less than the parameter count suggests, for the reason Chapter 6 gave: the core is a depthwise sandwich, and depthwise is memory-bound.

  • The lift is not from SE alone. EfficientNet’s paper thesis is compound scaling, tuning depth, width and resolution together with one coefficient to reach the ImageNet state of the art. At the B0 baseline shown here SE gives a modest bump, and the real wins arrive at B3–B7 where the scaling compounds. This example sits at B0 because the dataset is 10-class Imagenette rather than 1000-class ImageNet, and the return on scaling saturates much earlier.

7.4 MLIR: Squeeze-and-Excitation

What is already proven. Squeeze-excitation is an elementwise product \(\mathrm{se}(x) = x \odot g(x)\), where the per-channel gate \(g(x)\) is itself a small sub-network of the same input \(x\): global-average-pool, dense, swish, dense, sigmoid, broadcast. Because the gate depends on \(x\), the VJP is a product rule, not a plain scaling: seBlockHasVJP proves

\[ dx = g(x)\odot dy \; +\; g.\mathrm{back}\bigl(x \odot dy\bigr), \]

the gate-weighted cotangent plus the gate’s own backward applied to the co-input \(x\odot dy\). seBlockFullHasVJP supplies the concrete gate (the GAP–dense–swish–dense–sigmoid chain) and mbconvBodyHasVJP composes the whole MBConv.

The gap and how we close it. The emitted graph realizes seBlockHasVJP’s product-rule backward in full, both terms, with each piece of the gate carrying the swish, sigmoid and dense bridges of the earlier chapters. Here it is in verified_mlir/efficientnet_adam_train_step.mlir at the sixteenth MBConv block (1152 mid-channels, 48 in the squeeze, \(7\times 7\), batch 32), with the swish backward between the two dense backwards elided (SSA names the artifact’s, default precision dropped, lines wrapped):

%v1845 = stablehlo.logistic %v1844 : tensor<32x1152xf32>
%v1847 = stablehlo.broadcast_in_dim %v1845, dims = [0, 1]
      : (tensor<32x1152xf32>) -> tensor<32x1152x7x7xf32>
%v1848 = stablehlo.multiply %v1847, %v1846 : tensor<32x1152x7x7xf32>
%v1849 = stablehlo.multiply %v1832, %v1846 : tensor<32x1152x7x7xf32>
%v1850 = stablehlo.reduce(%v1849 init: %v1833)
      applies stablehlo.add across dimensions = [2, 3]
      : (tensor<32x1152x7x7xf32>, tensor<f32>) -> tensor<32x1152xf32>
%v1851 = stablehlo.constant dense<1.0> : tensor<32x1152xf32>
%v1852 = stablehlo.subtract %v1851, %v1845 : tensor<32x1152xf32>
%v1853 = stablehlo.multiply %v1845, %v1852 : tensor<32x1152xf32>
%v1854 = stablehlo.multiply %v1850, %v1853 : tensor<32x1152xf32>
%v1855 = stablehlo.dot_general %v1854, %b16zW2,
      contracting_dims = [1] x [1] : (tensor<32x1152xf32>,
      tensor<48x1152xf32>) -> tensor<32x48xf32>
// ...
%v1863 = stablehlo.dot_general %v1862, %b16zW1,
      contracting_dims = [1] x [1] : (tensor<32x48xf32>,
      tensor<1152x48xf32>) -> tensor<32x1152xf32>
%v1864 = stablehlo.constant dense<49.0> : tensor<32x1152xf32>
%v1865 = stablehlo.divide %v1863, %v1864 : tensor<32x1152xf32>
%v1866 = stablehlo.broadcast_in_dim %v1865, dims = [0, 1]
      : (tensor<32x1152xf32>) -> tensor<32x1152x7x7xf32>
%v1867 = stablehlo.add %v1848, %v1866 : tensor<32x1152x7x7xf32>

Read it against the product rule, with %v1846 the cotangent \(dy\) into the block and %v1832 its input \(x\):

  • %v1845 is the gate \(g(x)\), the logistic of the excite MLP’s output, recomputed; %v1848 is \(g(x)\odot dy\) after the broadcast %v1847, the term a naive generator would stop at.

  • %v1849 is \(x \odot dy\) and %v1850 reduces it over the spatial axes. That reduction is the adjoint of the gate’s broadcast, so it is the cotangent on the per-channel gate values. %v1851–%v1854 multiply by \(\sigma (1-\sigma )\), the sigmoid’s derivative, %v1855 and %v1863 are the two dense backwards (each contracting the output axis of its weight), and %v1865 divides by \(49 = 7 \cdot 7\), the adjoint of the global-average-pool, before %v1866 broadcasts the result back over the map.

  • %v1867 = %v1848 + %v1866 adds the two.

That final add is the product rule: the gradient that passes straight through the channel, plus the gradient that re-tunes the gate, summed. The leaf theorem is seBackBatched_faithful: the node denotes seBHasVJP’s backward at the saved activation \(x\). SE is not linear, so unlike the convolution leaves this one threads each example’s forward activation through the backward, and the batched statement is built from the per-example se_back_bridge row by row. Theorem 99 carries it through the sixteen blocks.

Caveats.

  • The SE fan-in carries no corner condition. Swish and sigmoid are smooth everywhere, and the only smooth-point caveats in an MBConv come from its BatchNorms (\(\epsilon {\gt} 0\)).

  • Shape. The listing is one block of the trained net; the theorem is stated at every channel count and squeeze width.

7.5 ImageNet recipe

As with ResNet-34 (Chapter 5) and MobileNet-V2, the B0 backbone scales to full 1000-class ImageNet unchanged. The MBConv stack stays put, the head widens to 1000, and the schedule grows. As there, the recipe runs on both paths — the reference first, then the verified path, then the two side by side.

The reference (JAX)

The reference trainer is jax/MainEfficientNetImagenet.lean; its spec, and its recipe, as the run below trained it:

-- Same MBConv (SE + Swish) backbone, 1000 classes instead of 10.
def efficientNetB0Imagenet : NetSpec where
  name   := "EfficientNet-B0 (ImageNet, bf16)"
  imageH := 224
  imageW := 224
  convBnAct := .swish   -- stem and head too, not just the MBConv interiors
  layers := [
    .convBn 3 32 3 2 .same,
    .mbConv  32  16 1 3 1 1 true,
    .mbConv  16  24 6 3 2 2 true,
    .mbConv  24  40 6 5 2 2 true,
    .mbConv  40  80 6 3 2 3 true,
    .mbConv  80 112 6 5 1 3 true,
    .mbConv 112 192 6 5 2 4 true,
    .mbConv 192 320 6 3 1 1 true,
    .convBn 320 1280 1 1 .same,
    .globalAvgPool,
    .dense 1280 1000 .identity      -- 1000-class head
  ]

-- TF TPU EfficientNet's own code, row for row; the table below names each source.
def efficientNetB0ImagenetConfig : TrainConfig where
  learningRate   := 0.016   -- 0.256 at batch 4096, scaled to 256
  batchSize      := 256
  epochs         := 80
  optimizer      := .rmsprop  -- EfficientNet's original optimizer
  momentum       := 0.9       -- μ for the RMSprop momentum buffer
  rmspropDecay   := 0.9       -- ρ, the running mean-square decay
  rmspropEps     := 1e-3      -- EfficientNet uses ε=1e-3
  gradClipNorm   := 0.0       -- off: the paper uses none
  weightDecay    := 1e-5
  wdExcludeNormBias := true   -- no decay on BN γ/β or biases
  cosineDecay      := false   -- the paper's exponential decay instead
  expLRDecayRate   := 0.97    -- ×0.97 every 2.4 epochs
  expLRDecayEpochs := 2.4
  expLRStaircase   := true    -- floored on the global step
  dropout          := 0.2     -- EfficientNet-B0 classifier dropout
  warmupEpochs   := 5
  augment        := true
  useAutoAugment := true     -- full AutoAugment ImageNet policy
  autoAugmentV0  := true     -- TF EfficientNet's policy v0 (timm `v0`)
  augBicubic     := false   -- TF's geometry is nearest, not PIL bicubic
  cropFallbackCenter := true -- a whole-image draw falls back to the centre crop
  labelSmoothing := 0.1
  bf16           := true
  bf16Conv       := true    -- reaches the MBConv expand/depthwise/project
  f32StemHead    := true    -- classifier head in fp32, as every verified render
  useEMA         := true     -- weight averaging at 0.9999, BN buffers shadowed too
  dropPath       := 0.2      -- stochastic depth, the drop-connect rate
  dropPathOverN  := true     -- the ramp 0.2 · i/16, not the default i/15
  runningBN      := true     -- paper-faithful eval: running BN stats
  bnEps          := 1e-3     -- TF's BN ε
  -- ── The rest of TF TPU EfficientNet's own code, set row by row ──
  rmspropTf      := true     -- TF1 ApplyRMSProp folds lr into the momentum buffer
  wdDecayBiases  := true     -- weight decay over every non-BN variable: biases too
  initHeadTfUniform := true  -- U(±1/√1000) on the classifier, zero bias
  autoAugmentTf  := true     -- the v0 table op for op, TF's fills and shifts
  augNearest     := true     -- contrib_image transforms interpolate NEAREST
  randAugmentTranslateConst := 250.0  -- translate_const 250: (m/10)·250 px
  resizeLegacyTf1 := true    -- TF1 resize_bicubic: no antialias
  evalCropTf     := true     -- crop first, 224/256 of the shorter side …
  evalCropTfEfficientNet := true  -- … with TF's offsets, then bicubic to 224
  evalF32        := true     -- the logged number through an f32 forward


/-- Paper-faithful full run: identical recipe at the 350-epoch schedule, at the
    paper's real LR 0.016 (the TF-RMSProp form trains stably at it —
    no lowered LR, no grad clip). Selected with the `full` recipe arg. -/
def efficientNetB0ImagenetConfigFull : TrainConfig :=
  { efficientNetB0ImagenetConfig with epochs := 350 }

The number this section reports is the full tier, the 350-epoch one, from the run on this listing. The recipe is not the paper’s sentence but TensorFlow’s own EfficientNet code read row by row — main.py, utils.py, efficientnet_model.py, preprocessing.py and autoaugment.py — and Table 7-2 names the source each value was read off.

Table 7-2 Where each value of the EfficientNet-B0 ImageNet config comes from: TF TPU EfficientNet’s own code, tensorflow/tpu models/official/efficientnet/.

Knob

Value

Source

learning rate

\(0.016\) at \(256\)

the reference base rate \(0.256\) at batch \(4096\), scaled linearly; paper-faithful, with no lowered rate and no gradient clip

optimizer

RMSProp, TF 1 form

utils.build_optimizer: tf.train.RMSPropOptimizer(lr, decay, momentum, epsilon); TF 1’s ApplyRMSProp folds the rate into the momentum buffer, \(\varepsilon \) inside the square root, mean square started at \(1\)

RMSProp \(\mu \), \(\rho \), \(\varepsilon \)

\(0.9\), \(0.9\), \(10^{-3}\)

the paper’s values; the TF-RMSProp form has no blow-up for a clip to compensate for, so none is set

weight decay

\(10^{-5}\), biases included

main.py: weight_decay times the sum of l2_loss(v) over every trainable variable without batch_normalization in its name, so BN \(\gamma /\beta \) exempt and biases decayed (wdDecayBiases); timm and the verified wx render exclude norm and bias

schedule

\(\times 0.97\) every \(2.4\) epochs, floored, 5-epoch warmup

TF: the exponential decay on the global step, floored; the warmup overrides it while it runs

dropout, drop path

\(0.2\), \(0.2\)

the B0 classifier dropout; stochastic depth at the drop-connect rate, on TF’s and timm’s ramp \(0.2 \cdot i/16\), not the default \(i/15\)

head init

\(U(\pm 1/\sqrt{1000})\), zero bias

efficientnet_model.py dense_kernel_initializer

AutoAugment

policy v0, TF’s ops

autoaugment.py: the v0 table op for op, TranslateY absolute, fill 128, TF’s posterize shift; contrib_image.{transform,rotate,translate} interpolate NEAREST, not timm’s PIL bicubic; HParams(cutout_const=100, translate_const=250), so \((m/10) \cdot 250\) px

train crop

whole-image draw falls back to the centre crop

preprocessing.py

resize

bicubic, TF 1 op

_resize_image: tf.image.resize_bicubic, no antialias, half_pixel_centers=False

eval crop

\(224/256\) of the shorter side, TF’s offsets

_decode_and_center_crop: crop first, the side truncated, offsets \(((h - \mathrm{side}) + 1)/2\), then bicubic to \(224\)

EMA

\(0.9999\), BN buffers shadowed

paper-faithful weight averaging; the emitter EMA-shadows the BN buffers too and eval uses them, since EMA weights with live BN statistics blow up at eval

BatchNorm

\(\varepsilon = 10^{-3}\), running stats at eval

TF’s BN \(\varepsilon \); paper-faithful eval

precision

bf16 MBConv convolutions, fp32 head, f32 eval

the reference’s own choice; the logged number is scored through an f32 forward, as the verified path’s and timm’s are

Two details are specific to EfficientNet. First, bf16Conv casts the MBConv block’s heavy convolutions (the \(1\times 1\) expand, the depthwise, the \(1\times 1\) project) but deliberately leaves the squeeze-excitation \(1\times 1\)s in fp32: SE acts on a globally-pooled \(1\times 1\) tensor where there is no throughput to win, and its sigmoid gate is precision-sensitive. The payoff is the same \(\sim 2\times \) MBConv-block speedup MobileNet sees, from exactly the convolutions worth casting.

Second, the SE bottleneck is sized off the block’s input channels (\(c/4\)), not the \(6\times \)-larger expanded width the squeeze sits behind. That distinction is most of the difference between a \(5.3\)M-parameter B0 and an \(8.4\)M one, and it is what makes the spec in §7.3 derive the canonical \(\mathbf{5.29}\)M.

Augmentation. The base is Inception-style random-resized-crop through TensorFlow’s sampler (10–100% of the image area at a 3/4–4/3 aspect ratio, a whole-image draw falling back to the centre crop) resized to \(224\times 224\) by TF 1’s bicubic kernel, plus a random horizontal flip, with label smoothing \(0.1\) in the loss; validation is TensorFlow’s _decode_and_center_crop, \(224/256\) of the shorter side then the same bicubic. On top of that the recipe turns on AutoAugment at policy v0 as autoaugment.py applies it: the table TensorFlow’s EfficientNet trains with, geometric operations included, at nearest-neighbour resampling with fill \(128\) and TranslateY in pixels. The heavier pack (Mixup, CutMix, RandAugment, Random Erasing) is wired into the reference trainer, emitted into the generated tf.data pipeline with each knob gated on a config flag, and left off here because the 2019 recipe reaches for AutoAugment instead. Table 7-3 is the run.

Table 7-3 EfficientNet-B0 on ImageNet-1k, reference trainer (JAX): CUDA, bf16, batch 256.

GPU

Epochs

Per epoch

Wall-clock

Val top-1

Val top-5

4\(\times \) 3060

350 (RMSProp)

\(\sim \)7.3 min

\(42.5\) hr

\(\mathbf{77.17\% }\)

\(\mathbf{93.39\% }\)

The run reached \(\mathbf{77.17\% }\) top-1 / \(\mathbf{93.39\% }\) top-5 on the full 50,000-image validation split against EfficientNet-B0’s published \(77.1\% \, /\, 93.3\% \) — a match on the number. In four details of the recipe the obvious implementation is not the one B0 was trained with.

RMSProp in TensorFlow’s form. B0’s optimizer is RMSProp (\(\rho =0.9\), \(\mu =0.9\), \(\varepsilon =10^{-3}\)) in TensorFlow’s variant of it, timm’s RMSpropTF: the update divides by \(\sqrt{\mathrm{sq}+\varepsilon }\), with \(\varepsilon \) inside the square root, and the mean-square accumulator initializes to \(1\). The textbook form puts \(\varepsilon \) outside (\(\sqrt{\mathrm{sq}}+\varepsilon \)) and starts the accumulator at \(0\); together those inflate the effective step by up to \(\sim \)30\(\times \) while the running mean-square is small. At B0’s scale that is enough to erode top-1 over training rather than diverge outright, which makes it read as a learning-rate sensitivity. MobileNet-V2 is indifferent to the choice only because its \(\varepsilon =1.0\) swamps the misplacement. In TF form B0 trains stably at the paper’s \(\mathrm{lr}=0.016\) with no gradient clipping and no lowered peak. TF 1’s ApplyRMSProp also folds the learning rate into the momentum buffer rather than applying it on the way out, and the two forms part by a little at every step of the staircase; the trainer carries TF 1’s (Theorem 87).

The EMA shadows the BatchNorm buffers as well as the weights. The paper’s weight EMA (decay \(0.9999\)) averages the parameters (Theorem 88); evaluation then has to pair those averaged weights with equally-lagged normalization statistics, so the trainer carries a second shadow over the BN running mean and variance (ema_bn) and evaluates the pair. Averaged weights against live BN statistics is a mismatch large enough to put validation loss at \(10^{8}\). LayerNorm carries no running buffers, so ConvNeXt and ViT run the same EMA without needing this.

The EMA decay is warmup-corrected. A flat \(0.9999\) from step \(0\) moves the shadow one ten-thousandth of the way toward the live weights per step, so for the first several thousand steps the evaluated weights are mostly the random initialization while the network trains normally underneath. The decay is therefore \(\min (0.9999,\, (1+t)/(10+t))\): it tracks the live weights early and relaxes to \(0.9999\) once there is something worth averaging. It costs nothing at convergence, and the opening epochs measure the model rather than the initialization.

Swish throughout, stem and head included. B0 is SiLU/swish everywhere, not only inside the MBConv blocks. The two .convBn layers that bracket the stack — the \(3\times 3\) stride-2 stem and the \(1\times 1\) head into the classifier — set convBnAct to swish rather than taking its .relu default. Both sit where the entire signal passes, so two activations decide what the whole network sees on the way in and on the way out.

The 350-epoch run. The full tier reached \(\mathbf{77.17\% }\) top-1 / \(\mathbf{93.39\% }\) top-5 against EfficientNet-B0’s paper \(77.1\% \, /\, 93.3\% \): level on top-1 and within \(0.1\) on top-5. The run is from scratch at the paper’s optimizer, learning rate, EMA and activation, with RMSProp at \(0.016\), AutoAugment, stochastic depth and the exponential staircase, and it departs from TensorFlow’s code in no line the listing names. It ran \(42.5\) hr on four RTX 3060 at \(7.3\) minutes per epoch, with no restart. The headline figure is the EMA weights scored over all 50,000 validation images through an fp32 forward; the same checkpoint’s live weights score \(76.63\% \) top-1, so the weight average is worth \(+0.54\) points. The last twenty-five epochs average \(77.18\, /\, 93.41\), so the final number is the tail’s mean, not the top of a rise. Figure 7-3 is the run, epoch by epoch.

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation accuracy (\%)},
    xmin=0, xmax=355, ymin=0, ymax=100,
    xtick={0,50,100,150,200,250,300,350}, ytick={20,40,60,80,100},
    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=0.8pt, mark=none},
]
\addplot[blue] coordinates {
(1,12.54) (2,32.29) (3,44.07) (4,49.48) (5,53.06) (6,56.45) (7,58.83) (8,60.64) (9,61.89) (10,62.81) (15,65.59) (20,67.19) (25,68.33) (30,69.39) (35,70.03) (40,70.42) (45,70.91) (50,71.30) (55,71.86) (60,71.91) (65,72.20) (70,72.66) (75,73.04) (80,73.28) (85,73.51) (90,73.54) (95,73.89) (100,74.01) (105,74.16) (110,74.31) (115,74.59) (120,74.49) (125,74.77) (130,74.89) (135,74.99) (140,75.09) (145,75.16) (150,75.21) (155,75.31) (160,75.43) (165,75.69) (170,75.77) (175,75.74) (180,75.88) (185,75.99) (190,76.08) (195,76.02) (200,76.14) (205,76.29) (210,76.38) (215,76.40) (220,76.38) (225,76.55) (230,76.50) (235,76.61) (240,76.59) (245,76.56) (250,76.67) (255,76.69) (260,76.70) (265,76.86) (270,76.94) (275,76.94) (280,77.01) (285,77.01) (290,77.02) (295,77.05) (300,77.06) (305,76.99) (310,77.07) (315,77.17) (320,77.05) (325,77.14) (330,77.05) (335,77.18) (340,77.19) (345,77.30) (350,77.17)
};
\addlegendentry{top-1}
\addplot[orange] coordinates {
(1,30.19) (2,58.12) (3,69.66) (4,74.47) (5,77.41) (6,80.12) (7,81.86) (8,83.01) (9,83.95) (10,84.71) (15,86.60) (20,87.60) (25,88.44) (30,89.00) (35,89.31) (40,89.67) (45,89.88) (50,90.09) (55,90.31) (60,90.31) (65,90.63) (70,90.84) (75,91.10) (80,91.12) (85,91.36) (90,91.41) (95,91.57) (100,91.66) (105,91.75) (110,91.87) (115,91.86) (120,91.93) (125,92.05) (130,92.10) (135,92.19) (140,92.24) (145,92.27) (150,92.37) (155,92.42) (160,92.51) (165,92.53) (170,92.57) (175,92.59) (180,92.63) (185,92.69) (190,92.70) (195,92.76) (200,92.85) (205,92.85) (210,92.85) (215,92.86) (220,92.92) (225,93.05) (230,93.03) (235,93.08) (240,93.08) (245,93.10) (250,93.13) (255,93.19) (260,93.14) (265,93.17) (270,93.21) (275,93.18) (280,93.26) (285,93.24) (290,93.23) (295,93.30) (300,93.34) (305,93.38) (310,93.43) (315,93.39) (320,93.37) (325,93.44) (330,93.43) (335,93.33) (340,93.39) (345,93.48) (350,93.39)
};
\addlegendentry{top-5}
\end{axis}
\end{tikzpicture}
Figure 7-3 Validation accuracy per epoch of the 350-epoch EfficientNet-B0 reference run: RMSProp, bf16, \(4\times \) 3060, scored over all \(50{,}000\) validation images at every epoch.

The final \(77.17\% \, /\, 93.39\% \) matches the paper’s \(77.1\% \, /\, 93.3\% \). The flattening past epoch \(\sim \)300 is the exponential schedule running out of learning rate (\(0.016 \to 1.9\times 10^{-4}\)), not saturation of the architecture.

The verified path (PJRT)

The verified trainer trains the same network through the proof-rendered StableHLO that §7.1 ran at Imagenette scale. It is apps/imagenette/MainEfficientNetImagenet.lean, built as lake build efficientnet-imagenet-verified:

-- Same MBConv + SE backbone, 1000 classes instead of 10.
def efficientnetImagenetVerified : VerifiedNetSpec where
  name       := "EfficientNet-B0 (ImageNet-1k)"
  slug       := "efficientnetin"
  data       := .imagenet
  shimScript := "generated_efficientnet_b0_imagenet_shim.py"
  layers     := [ ... the Imagenette stack, 1000-class head ... ]

-- 350 epochs at 64 PER DEVICE; four replicas give the global 256.
-- 350 because that is the schedule the reference number above was
-- measured on.
def efficientnetImagenetConfig : VerifiedConfig where
  epochs    := 350
  batchSize := 64

def main (argv : List String) : IO Unit :=
  efficientnetImagenetVerified.toNet.trainAdamSched
    efficientnetImagenetConfig (argv.head?.getD "data") ... variant

slug names verified_mlir/efficientnetin_<variant>_train_step.mlir, the rendered graph, and shimScript names this net’s own augmentation shim, which matters more here than it does for MobileNetV2 because EfficientNet’s reference config turns on the full AutoAugment policy and streaming another net’s shim would drop it. The head is the only parameter shape that moves, from \(1280 \times 10\) to \(1280 \times 1000\), which takes the count from 4,020,358 to 5,288,548. That second number is B0’s canonical \(5.29\)M, the count the correctly-sized squeeze restores.

The recipe, on both paths. RMSProp \(\rho \, 0.9\) / \(\mu \, 0.9\) / \(\varepsilon \, 10^{-3}\), lr \(0.256\) at batch \(4096\) (so \(0.016\) at \(256\)), weight decay \(10^{-5}\), the exponential staircase \(\times 0.97\) every \(2.4\) epochs after a five-epoch warmup, dropout \(0.2\), drop-connect \(0.2\) ramped as \(0.2 \cdot i/16\), AutoAugment v0, label smoothing \(0.1\), EMA \(0.9999\) over the weights and the BatchNorm buffers, BN momentum \(0.99\) and \(\varepsilon \, 10^{-3}\), 350 epochs. All of it is carried on both paths, including every place TensorFlow’s code differs from a naive port: \(\varepsilon \) inside the square root (\(g/\sqrt{s+\varepsilon }\), not \(g/(\sqrt{s}+\varepsilon )\)) with the mean-square accumulator started at \(1.0\); the learning rate folded into the momentum buffer; weight decay off BatchNorm’s \(\gamma \) and \(\beta \) alone, so the biases decay; SAME padding on the four strided depthwise convolutions; TensorFlow’s crop sampler with its centre-crop fallback and TF 1’s bicubic resize at train and eval; the classifier initialized \(U(\pm 1/\sqrt{1000})\) with the depthwise and squeeze-excite fan rules; an fp32 classifier head and an fp32 evaluation forward; and BatchNorm synchronised over the four replicas.

Two of those rows are easy to get wrong, and the parity gate measures both. BatchNorm’s \(\varepsilon \) is \(10^{-3}\), the TF value, on both paths: bnEps on the reference, and the eps0001 render with its efficientnetin_fwd_eval_eps0001 scorer on the verified path. And the four strided depthwise convolutions pad as TensorFlow’s SAME does, the odd pixel of padding on the bottom and right; a symmetric pad splits it evenly, so at stride two the depthwise window sits one pixel off, and measured against tf_efficientnet_b0 on shared weights (scripts/parity/enet_timm_parity.py) that one pixel is \(0.37\) of the logit scale at training resolution and \(0.048\) at evaluation. The reference’s mbconv_block passes ’SAME’ to its depthwise, and the verified render emits the odd-phase strided depthwise at its four sites (depthwiseStride2FlatXla, the stem’s convention: a symmetric stride-one convolution read at the odd positions), with the block, step and data-parallel ties stated on that form; the gate pins tf_efficientnet_b0 itself and shows the symmetric pad red.

The activation agrees on both sides. The verified render is swish throughout — \(178\) stablehlo.logistic and not one stablehlo.maximum — and the reference sets convBnAct to swish on the stem and the \(1\times 1\) head. The two paths describe the same network.

The two paths, side by side

Table 7-4 is the two paths, row by row.

Table 7-4 EfficientNet-B0 on ImageNet-1k: the reference and the verified path, row by row. Both are TensorFlow’s EfficientNet recipe at 350 epochs, on the same four RTX 3060 cards, scored over the same \(50{,}000\) validation images; rows above the first rule differ, rows between the rules are held equal by construction.
 

reference (JAX)

verified path (PJRT)

lowering

JAX \(\to \) XLA

pretty(provenGraph) \(\to \) XLA

Python in the step

yes

none

per epoch

7.3 min

11.2 min

total

42.5 hr

65.1 hr

precision

bf16 convolutions

bf16 convolutions

classifier head

fp32

fp32

evaluation forward

fp32

fp32

init

TF’s: head \(U(\pm 1/\sqrt{1000})\), depthwise and SE fans

TF’s: head \(U(\pm 1/\sqrt{1000})\), depthwise and SE fans

global batch

256 (\(4\times 64\))

256 (\(4\times 64\))

BN statistic group

256 (global)

256 (global)

steps/epoch

5,004

5,004

optimizer

TF 1 RMSProp, \(\varepsilon =10^{-3}\)

TF 1 RMSProp, \(\varepsilon =10^{-3}\)

schedule

\(\times 0.97\) / \(2.4\) ep staircase, 5-ep warmup

\(\times 0.97\) / \(2.4\) ep staircase, 5-ep warmup

weight decay

\(10^{-5}\), BN \(\gamma ,\beta \) exempt

\(10^{-5}\), BN \(\gamma ,\beta \) exempt

augmentation

TF sampler + AutoAugment v0

TF sampler + AutoAugment v0

label smoothing

\(0.1\)

\(0.1\)

weights scored

EMA \(0.9999\) + BN buffers, epoch 350

EMA \(0.9999\) + BN buffers, epoch 350

val top-1

\(\mathbf{77.17\% }\)

\(\mathbf{77.21\% }\)

val top-5

\(\mathbf{93.39\% }\)

\(\mathbf{93.46\% }\)

The verified column is the emarmstfdp64dropdowneps0001bf16 render, the one lake run enet-default-4gpu trains, with synchronised BatchNorm. The reference’s in-training evaluation reads \(77.17\% \) at epoch 350 and its saved state rescored through the same fp32 forward counts the same \(38{,}584\) images, so the two numbers are on the same footing; the verified run’s \(77.21\% \) is \(38{,}604\). The verified run’s \(65.1\) h include six planned refreshes of its loader feed, a minute or so each, and one slow stretch of eight epochs behind a degraded loader, about fifteen minutes in all; no restart.

The two paths tie, \(+0.04\) on top-1 (20 images) and \(+0.07\) on top-5, a tenth of the 95% Wilson half-width (\(\pm 0.37\) at \(77\% \) on \(50{,}000\) images) at one seed each, and the curves sit on each other the whole way: the training losses agree to within \(0.007\) in every 50-epoch window, with the verified run’s a little lower in each, and the sampled accuracies never part by more than \(0.30\) at any epoch in the table. Paired on the same images, the two checkpoints disagree on \(4{,}400\), \(2{,}210\) against \(2{,}190\), and McNemar’s exact test gives \(p = 0.77\): they cannot be told apart on this validation set. The live weights, scored beside the averaged ones, tell the same story at \(p = 0.41\), and put the weight average at \(+0.54\) on the reference and \(+0.70\) on the verified path.

The verified curve runs a hair ahead. It leads on top-1 at \(284\) of the \(350\) epochs, by \(+0.02\) on average over the first fifty, by as much as \(+0.23\) over epochs 151–200, and by \(+0.05\) over the last fifty; the mean of epochs 326–350 is \(77.23\) against \(77.18\). None of it is outside one seed’s noise. Table 7-5 is the selected epochs and Figure 7-4 every ten.

Table 7-5 EfficientNet-B0 validation top-1 on both paths at selected epochs, the weight average scored.

epoch

10

50

100

150

200

300

350

reference (JAX)

62.81

71.30

74.01

75.21

76.14

77.06

77.17

verified path (PJRT)

62.95

71.42

74.19

75.51

76.14

77.01

77.21

\(\Delta \)

\(+0.14\)

\(+0.12\)

\(+0.17\)

\(+0.30\)

\(+0.01\)

\(-0.05\)

\(+0.04\)

\begin{tikzpicture} 
\begin{axis}[
    width=0.92\linewidth, height=6.5cm,
    xlabel={Epoch}, ylabel={Validation top-1 (\%)},
    xmin=0, xmax=351, ymin=0, ymax=80,
    xtick={0,50,100,150,200,250,300,350},
    ytick={20,40,60,80},
    legend pos=south east,
    legend cell align={left},
    grid=major, grid style={gray!18},
    tick label style={font=\small},
    label style={font=\small},
    every axis plot/.append style={line width=1pt, mark size=0.8pt},
]
\addplot[blue, mark=*, mark options={fill=blue}] coordinates {
(1,12.54) (10,62.81) (20,67.19) (30,69.39) (40,70.42) (50,71.30) (60,71.91) (70,72.66) (80,73.28) (90,73.54) (100,74.01) (110,74.31) (120,74.49) (130,74.89) (140,75.09) (150,75.21) (160,75.43) (170,75.77) (180,75.88) (190,76.08) (200,76.14) (210,76.38) (220,76.38) (230,76.50) (240,76.59) (250,76.67) (260,76.70) (270,76.94) (280,77.01) (290,77.02) (300,77.06) (310,77.07) (320,77.05) (330,77.05) (340,77.19) (350,77.17)
};
\addlegendentry{reference (JAX)}
\addplot[red, dashed, mark=o] coordinates {
(1,13.07) (10,62.95) (20,67.32) (30,69.13) (40,70.34) (50,71.42) (60,72.19) (70,72.95) (80,73.30) (90,73.82) (100,74.19) (110,74.44) (120,74.72) (130,75.10) (140,75.28) (150,75.51) (160,75.65) (170,75.80) (180,76.13) (190,76.25) (200,76.14) (210,76.34) (220,76.50) (230,76.62) (240,76.69) (250,76.80) (260,76.86) (270,76.99) (280,76.98) (290,77.00) (300,77.01) (310,77.12) (320,77.16) (330,77.19) (340,77.26) (350,77.21)
};
\addlegendentry{verified path (PJRT)}
\end{axis}
\end{tikzpicture}
Figure 7-4 EfficientNet-B0 validation top-1 on both paths, every ten epochs: one recipe, two paths to XLA, scored over all \(50{,}000\) validation images.

The two curves start together as well, \(12.54\) against \(13.07\) at epoch 1, with the initializations and the seeding of the BatchNorm running statistics held equal.

What the EfficientNet recipe changes from the 2018 recipe

The ResNet-34 trainer of Chapter 5 is the 2018-era recipe: SGD with momentum plus the “bag of tricks” polish (cosine schedule, warmup, label smoothing, random-resized-crop). The config above is the 2019 EfficientNet recipe (Tan & Le). Chapter 5’s RSB-A3 side quest shows what 2021 does to the 2018 baseline, and Chapter 8’s recipe table is 2022’s version. Table 7-6 is the 2019 rung of the same ladder. The trainAdamSched entry point is identical in both columns, and every row is a one-line change to the VerifiedNetSpec, the VerifiedConfig, or the arguments beside them.

Table 7-6 The EfficientNet-B0 recipe against ResNet-34’s 2018 recipe, knob by knob.

Knob

ResNet-34 (2018)

EfficientNet-B0 (2019)

What the B0 choice buys

Backbone

basic block, 21.8 M

MBConv + SE + Swish, 5.3 M

depthwise sandwich + SE gating — ResNet-34-class accuracy at a quarter of the parameters

Optimizer

SGD + momentum \(0.9\)

RMSProp (TF form)

\(\rho {=}0.9\), \(\mu {=}0.9\), \(\varepsilon {=}10^{-3}\) — and the exact variant matters (below)

Peak LR

\(0.1\)

\(0.016\)

linearly scaled from the official \(0.256\)@4096 — RMSProp’s scale, not comparable to SGD’s \(0.1\)

LR schedule

cosine to zero

exponential, \(\times 0.97\) every 2.4 epochs

the paper’s staircase, floored on the global step, under the same 5-epoch warmup

Epochs

90

350

the long-schedule regime the regularizers need, and the row the exponential decay above makes non-negotiable

Weight decay

\(10^{-4}\), weights only

\(10^{-5}\), BN \(\gamma ,\beta \) excluded

\(10\times \) weaker — depthwise and SE parameters are decay-sensitive

Augmentation

RRC + hflip

+ AutoAugment v0

the learned ImageNet policy, in TensorFlow’s table — 2019’s forerunner of ConvNeXt’s DeiT pack

Stochastic depth

none

drop-path \(0.2\)

randomly drops whole residual branches per step, at B0’s drop-connect rate

EMA

none

decay \(0.9999\)

evaluates a smoothed shadow of the weights and the BN buffers (below)

What carries over unchanged: softmax cross-entropy with label smoothing \(0.1\), the 5-epoch warmup, random-resized-crop + horizontal flip as the base augmentation, batch 256, train and eval both at \(224\), bf16 convolution (the SE \(1\times 1\)s and the classifier head in fp32), and no gradient clipping, which is unlike every later recipe in this ladder.

The two quietest rows carry the chapter’s two debugging lessons, both told in full above. “RMSProp (TF form)” is doing real work: \(\varepsilon \) inside the square root and a mean-square accumulator that starts at \(1\) are the difference between training stably at the paper’s \(0.016\) and eroding top-1 over the run. And the EMA row only evaluates correctly because the BN running statistics are shadowed alongside the weights (ema_bn); averaged weights against live BN statistics is an eval blow-up, a failure mode specific to BatchNorm nets trained with weight averaging.

The through-line of the 2018\(\to \)2019 shift is “smaller, longer, more automated”: a quarter of the parameters trained for \(4\times \) the epochs, with a learned augmentation policy, stochastic depth, and averaged weights supplying the regularization. It is also a reminder that “the paper recipe” includes the optimizer’s exact semantics, not just its hyperparameters.

7.6 Recap

Three operations and the end of a hypothesis. The squeeze-and-excitation gate multiplies a feature map by a gain computed from its own global average, so its backward is the product rule applied to a network, the multiplicative fan-in that completes the three composition rules. Swish and the sigmoid inside the gate are smooth, and that is what Theorem 97 buys: the full B0, sixteen MBConv blocks, with its backward proved at every input, where every network before it had to name a point and assume the activations stayed off their corners. The step is tied (Theorem 99) and the data-parallel twin holds.

Three follow-ups extended the family in orthogonal directions, and none needs a new primitive. Noisy Student (Xie et al. 2019) is semi-supervised distillation: a teacher trained on labeled ImageNet pseudo-labels JFT-300M, a larger student trains on both under heavy augmentation (RandAugment, dropout, stochastic depth, the “noise” of the title), and the loop repeats, which took EfficientNet-L2 to \(88.4\% \) top-1. EfficientDet (Tan et al. 2020) puts the backbone under a bidirectional feature pyramid, BiFPN, which fuses each resolution with its coarser and finer neighbours by weighted sums, and a small per-anchor MLP predicts class and box at every pyramid level. EfficientNetV2 (Tan & Le 2021) replaces the early stages’ expand-\(1\times 1\) plus depthwise \(k\times k\) with one regular \(k\times k\) convolution, Fused MBConv: more FLOPs, less wall clock, because depthwise convolution is cheap in arithmetic and expensive in memory traffic. The fused block is already in the kit as .fusedMbConvNB, MobileNetV4’s stage 0 (§6.6), so V2 is a spec away.

The chapter’s other lesson is that a recipe is a specification, not a list of hyperparameters: RMSProp in TensorFlow’s form, the EMA shadowing the BatchNorm buffers, the warmup-corrected decay, each a detail that moves the number. The two paths land close and not on top of each other, and the section says which rows are the suspects. The next chapter takes the recipe idea to its conclusion, a ResNet modernized one transformer-era choice at a time, and brings the two operations that era runs on, LayerNorm and GELU.