7.7. Examples
Two tensors can print the same decimal digits and still contain different numbers. We will start there, with four entries whose sums we can check by hand. Keeping the calculations small also lets us follow a gradient back to its factors and an interval bound back to its endpoints. When the examples reach training and imported graphs, those calculations give us something concrete to compare with the command output.
Every Lean block on this page is elaborated when the site is built, and its output is checked against the accompanying transcript. Shell and Python transcripts are illustrative captures checked separately from the site build; rerun them when changing the executable, backend, or external dependency.
Tensor Shapes And Scalar Types
A Tensor α s carries its shape s in its type and its arithmetic in its element type α. Both
choices are visible in the first quickstart. The shape controls which operations can compose; the
element type determines how their arithmetic is evaluated.
-- Hold the values comparable while changing rank and scalar
-- arithmetic.
#eval show IO Unit from do
let threshold : Tensor Float [] := 0.5
let floats : Tensor Float [4] := [0.1, 0.2, 0.3, 0.4]
let rationals : Tensor ℚ [4] := [0.1, 0.2, 0.3, 0.4]
let integers : Tensor Int [4] := [1, 2, 3, 4]
IO.println s!"rank zero = {threshold}"
IO.println s!"Float = {floats}"
IO.println s!"Rat = {rationals}"
IO.println s!"Int = {integers}"
The rank-zero tensor has shape [], not a special scalar type. That is a deliberate choice: a loss
value and a batch of images then have the same type constructor, so Trainer does not need a
separate code path for the scalar it minimizes.
Writing 0.1 at ℚ produces the exact number
\tfrac1{10}, and the display says so. Writing 0.1 at Float produces the nearest binary64
value, which is not \tfrac1{10} (Goldberg, 1991). The two rows look almost
identical at this display precision, despite representing different numbers.
A scalar loss still has one stored value: an empty list of axes has size one. By contrast,
a shape such as [0] has an axis with no entries. Keeping those cases separate matters when
reductions or empty batches are involved. The rank-zero threshold above is therefore an ordinary
input to scalar tensor operations, while the four-element examples let us inspect how a reduction
combines several stored values into that same scalar shape.
Floating-Point And Rational Arithmetic
-- Test equality separately from the rounded tensor display.
#eval show IO Unit from do
let floats : Tensor Float [4] := [0.1, 0.2, 0.3, 0.4]
let rationals : Tensor ℚ [4] := [0.1, 0.2, 0.3, 0.4]
let floatSum := Tensor.sumSpec floats
IO.println s!"Float sum = {floatSum}"
IO.println s!"Rat sum = {Tensor.sumSpec rationals}"
IO.println s!"Float sum 1 = {floatSum == 1.0}"
IO.println s!"0.1+0.2=0.3 = {(0.1 : Float) + 0.2 == 0.3}"
IO.println s!"exact = {(0.1 : Rat) + 0.2 == 0.3}"
The equality tests distinguish values that the six-decimal display hides. 0.1 + 0.2 is not
0.3 in binary64.
But summing all four tenths does give exactly 1.0, because the rounding errors happen to cancel
along that particular left fold. Python binary64 arithmetic gives the same answers for this
explicitly ordered expression:
# Preserve the addition order used by the four-element # reduction. print(0.1 + 0.2 == 0.3, 0.1 + 0.2 + 0.3 + 0.4 == 1.0)
False True
The printed digits alone cannot establish equality. TorchLean keeps Float, Float32,
FloatLib binary32, ℚ, and \mathbb R as separate element types so that definitions and theorems
identify the arithmetic they use. See the floating-point chapter and
Boldo et al. (2015) for the same separation in Coq.
The rational calculation is a useful reference because its result does not depend on a rounding policy. It is not a replacement for checking the requested runtime arithmetic. If the application stores binary32 weights, a theorem about the corresponding rational expression still needs a connection to conversion and rounding. I use these decimals because we can keep their exact meanings in view while comparing the two arithmetic paths.
Rank, Slices, And The Reshape Guard
-- Reinterpret four stored elements as two rows without
-- changing their order.
#eval show IO Unit from do
let cube : Tensor Float [2, 2, 2] :=
[[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
let digits : Array Float := #[1.0, 2.0, 3.0, 4.0]
let flat := Tensor.from digits
IO.println s!"cube = {cube}"
IO.println s!"mat = {flat.reshape [2, 2]}"
Tensor.reshape takes the size equality as an argument with by decide as its default, so a
reshape that does not preserve the element count is rejected while the file is being elaborated:
-- Four source elements cannot supply the nine entries
-- required by this result type.
def exDigits : Array Float := #[1.0, 2.0, 3.0, 4.0]
def exBroken : Tensor Float [3, 3] :=
(Tensor.from exDigits).reshape [3, 3]
The second half of the message identifies the failed proposition and how it was decided.
The equality is the entire safety condition: reshape does not pad, truncate, or move data, so if the counts agree the operation is a reinterpretation of the same buffer. Stating it as a proof obligation erases the size check at runtime. It prevents a mismatched element count; callers still need to choose the intended axis order, and native storage remains a separate implementation boundary.
A PyTorch operation can report incompatible shapes at execution time. This addition example illustrates that timing:
# These non-singleton dimensions cannot be made equal by # broadcasting. torch.zeros(2, 2) + torch.zeros(3, 3)
RuntimeError: The size of tensor a (2) must match the size of tensor b (3) at non-singleton dimension 1
The addition fails when executed (Paszke et al., 2019). The Lean reshape above fails during elaboration because its size-equality obligation is false. These checks establish specific shape requirements; neither establishes that the program implements the intended model.
The command that prints the tensors above is
TensorBasics.lean:
# Print the standalone tensor quickstart, including its # explicit Float32 cast. lake exe torchlean quickstart_tensors
== Quickstart: tensor basics == Rank-zero tensor: 0.500000 Float tensor: [0.100000, 0.200000, 0.300000, 0.400000] Rational tensor: [(1 : Rat)/10, (1 : Rat)/5, (3 : Rat)/10, (2 : Rat)/5] Integer tensor: [1, 2, 3, 4] Float32 cast: [0.100000, 0.200000, 0.300000, 0.400000] Rank-3 tensor: [[[1.000000, 2.000000], [3.000000, 4.000000]], ... Array -> matrix: [[1.000000, 2.000000], [3.000000, 4.000000]]
The rank-3 line is truncated here to fit the page; it is the same value the block above printed. Read the source beside the output, change one element type at a time, and let Lean show which operations need a different algebraic context.
The cube and the matrix also separate shape from meaning. The same four values can represent two points with two features each, or a two-by-two image. Lean checks the chosen dimensions, but the program supplies their interpretation. In later examples a leading axis means a batch, a trailing axis means features or classes, and transposing them changes the computation even when their sizes happen to agree. Naming those roles beside the values is part of specifying the model.
Function And Model Differentiation
To compute a gradient, we first choose what may vary. autograd.grad takes the input tensor as
that variable; autograd.model.grad takes the model's typed state. We can see the difference by
differentiating a mean of squares, then a loss for one affine layer.
-- Differentiate a scalar mean with respect to all three
-- input coordinates.
def exMeanSquare : autograd.Function [3] [] :=
fun x => do
let squared ← nn.functional.square x
nn.functional.mean squared
#eval show IO Unit from do
let x : Tensor Float [3] := [1.0, 2.0, 3.0]
let (g, v) ← autograd.grad exMeanSquare x (value := true)
IO.println s!"mean(x^2) = {v}"
IO.println s!"d/dx = {g}"
For f(x)=\tfrac13\sum_i x_i^2 we have
\partial f/\partial x_i = \tfrac23 x_i, so the gradient at (1,2,3) is
(\tfrac23,\tfrac43,2), and f(1,2,3)=\tfrac{14}3=4.6\overline6. Every digit above matches.
PyTorch computes the same thing by the same rules (Baydin et al., 2018):
# Match binary64 arithmetic and seed reverse mode with the # scalar output cotangent. x = torch.tensor([1.0, 2.0, 3.0], dtype=torch.float64, requires_grad=True) y = (x ** 2).mean() y.backward() print(y.item(), x.grad.tolist())
4.666666666666667 [0.6666666666666666, 1.3333333333333333, 2.0]
The extra digits are the display, not the arithmetic: PyTorch prints a shortest round-trip representation of binary64, and TorchLean's tensor display rounds to six decimals.
The input gradient has shape [3] because it answers one question per input coordinate:
how would the scalar mean change if that coordinate moved? The division by three belongs to
the objective, so it must appear in every coordinate derivative. Replacing the mean with a sum
would leave the intermediate squared tensor unchanged and triple the returned gradient. This
small example makes a missing reduction factor visible without depending on parameter
initialization or a training loop.
Differentiating A Model
The second path takes a model and a loss, and returns a gradient shaped like the model's state:
-- The differentiation variable is the typed weight-and-bias
-- state.
def exModel : nn.Sequential [2] [1] :=
nn.build 0 (nn.linear 2 1)
#eval show IO Unit from do
let state : autograd.model.State exModel Float :=
autograd.model.initialState exModel
let input : Tensor Float [2] := [0.5, -1.0]
let target : Tensor Float [1] := [0.25]
let (g, loss) ← autograd.model.grad exModel
autograd.model.Loss.meanSquaredError state input target
(value := true)
IO.println s!"loss = {loss}"
IO.println s!"gradient = {reprStr g}"
The gradient prints with its shapes, [1, 2] for the weight and [1] for the bias, which is the
same list nn.stateShapes reports for this model. Their values follow from the chain rule. For
y=Wx+b and a squared-error loss on one output,
\frac{\partial L}{\partial b}=2(y-t),
\qquad
\frac{\partial L}{\partial W}=2(y-t)\,x^{\top},
\qquad
L=(y-t)^2.
So the bias gradient should be twice the residual, and the weight gradient should be that same
number times the input (0.5,-1). The loss determines the residual magnitude. Its sign also
requires the prediction or the signed
gradient; the following calculation uses the negative sign from this example's bias gradient:
-- Recover the residual magnitude and use the observed
-- gradient sign.
#eval show IO Unit from do
let state : autograd.model.State exModel Float :=
autograd.model.initialState exModel
let input : Tensor Float [2] := [0.5, -1.0]
let target : Tensor Float [1] := [0.25]
let (_, loss) ← autograd.model.grad exModel
autograd.model.Loss.meanSquaredError state input target
(value := true)
let residual := Float.sqrt (Tensor.at loss ())
IO.println s!"|y - t| = {residual}"
IO.println s!"2 (y - t) = {-2.0 * residual}"
IO.println s!"times 0.5 = {-2.0 * residual * 0.5}"
-1.754865 is the printed bias gradient and -0.877432 is the printed first weight gradient, to
every digit shown. The sign tells us the untrained prediction is below the target. This calculation
checks the chain rule and the squared-error convention against the displayed gradient.
The same identity can be checked in PyTorch with explicitly chosen parameters:
# Fix the parameters explicitly so the chain-rule arithmetic
# can be checked by hand.
lin = torch.nn.Linear(2, 1).double()
with torch.no_grad():
lin.weight.copy_(torch.tensor([[0.1, 0.2]], dtype=torch.float64))
lin.bias.copy_(torch.tensor([0.0], dtype=torch.float64))
x = torch.tensor([0.5, -1.0], dtype=torch.float64)
t = torch.tensor([0.25], dtype=torch.float64)
loss = ((lin(x) - t) ** 2).sum()
loss.backward()
loss : 0.16000000000000003 grad W : [[-0.4, 0.8]] grad b : [-0.8] 2(y - t) : -0.8
Here y = 0.1\cdot0.5 + 0.2\cdot(-1) = -0.15, so y-t=-0.4, the loss is 0.16, the bias
gradient is -0.8, and the weight gradient is -0.8\cdot(0.5,-1)=(-0.4,0.8). Same three
relations, different initialization. The trailing 3 in 0.16000000000000003 is the binary64
rounding of (-0.4)^2 showing through PyTorch's round-trip display.
The two gradient entry points differ in what they differentiate:
-
autograd.graddifferentiates a scalar tensor function with respect to its input tensor; -
autograd.model.graddifferentiates a loss with respect to the model state; -
(value := true)also returns the scalar value from the same evaluation, so a training loop does not pay for a second forward pass to report the loss it just differentiated.
Training normally calls the second path through Trainer, which manages the differentiation
invocation for each update.
A state gradient is useful because an optimizer must update the same collection of tensors that produced the prediction. The weight row and bias vector have different shapes and different roles; concatenating their displayed numbers would lose that information. The typed state keeps the pairing available through differentiation and the subsequent update. Input differentiation instead treats those parameters as fixed and asks how the model responds to a changed example. Both are chain-rule computations, but they answer different application questions.
Higher-Order And Directional Transforms
# Inspect Jacobian, Hessian, directional, and detached-state # results together. lake exe torchlean autograd_transforms
Jacobian rows of x^2:
[1.000000, 0.000000]
[0.000000, -2.400000]
Hessian columns of mean(x^2):
[1.000000, 0.000000]
[0.000000, 1.000000]
loss directional derivative = 0.011629
loss Hessian-vector product =
[[3, 2]: [[0.010000, -0.024000], [0.010000, -0.024000],
[0.010000, -0.024000]], [3]: [0.020000, 0.020000, 0.020000]]
state gradient after detaching the model output =
[[3, 2]: [[0.000000, 0.000000], [0.000000, 0.000000],
[0.000000, 0.000000]], [3]: [0.000000, 0.000000, 0.000000]]
The last two entries are printed on one line each by the command and were rewrapped to fit here.
The Jacobian of x\mapsto x^2 is diagonal with entries 2x_i, so the rows above say the input
was (0.5,-1.2). The Hessian of \tfrac1n\sum x_i^2 is \tfrac2n I, which at n=2 is the
identity at every input, so these columns do not depend on the evaluation point.
After detaching the model output, every component of the state gradient
is 0.000000, not merely small. Stop-gradient is not an approximation: the detached subgraph
contributes nothing to the reverse pass, so the result is a structural zero rather than a
cancellation that happened to work out. That is the mechanism behind target networks in
reinforcement learning and behind the stop-gradient branches in self-supervised objectives.
The model is a single affine layer with three outputs and mean squared error. Its probe sets every
weight and bias perturbation to 0.1. With input x=(0.5,-1.2), each output perturbation is
0.1(0.5-1.2)+0.1=0.03. The loss averages three squared residuals, so the bias component of the
Hessian-vector product is (2/3)\cdot0.03=0.02; multiplying by x gives the weight row
(0.01,-0.024). All three rows agree because the affine outputs use the same input, loss
weighting, and probe. Sharing an input alone would not imply this for a different loss or model.
Sources:
AutogradBasics.lean
and
AutogradTransforms.lean.
These executions are useful checks, not theorems that every runtime derivative agrees with real
calculus; the autograd chapter is where that question is taken up.
The directional derivative and Hessian-vector product answer questions that a full matrix dump can obscure. The first gives the local loss change along one chosen parameter perturbation; the second gives how the gradient changes along that perturbation. Their tensor layouts still match the parameter state, so the three weight rows and three biases remain distinguishable. A zero Hessian-vector entry would concern curvature in that direction, whereas the detached gradients above are zero because a computation path has been cut. Reading the operation that produced a zero is as important as reading its printed value.
MLP Training
The training quickstart generates 25 regression samples for a two-input, one-output function and uses a hidden layer of width eight.
# Keep the seed fixed for a short initial training trace. lake exe torchlean quickstart_mlp \ --device cpu --steps 20 --seed 2026
== Quickstart: simple MLP training (seed=2026, steps=20) == target(heldout) = [0.200000] untrained(heldout) = [-0.088261] dataset size = 25 mean_loss(before training) = 0.495227 step 0: loss=0.250488 mean_loss(after training) = 0.401184 steps=20 arithmetic=native scalar=Float32 loss=0.495227 -> 0.401184 trained(heldout) = [0.365380]
Two statistics appear here and they measure different things. step 0: loss=0.250488 is the loss on
the first streamed sample; mean_loss covers all 25. A per-sample loss can be far below the dataset
mean simply because that sample was easy, which is why both are labelled rather than reported as one
number called "loss".
At the held-out point, the untrained model predicts -0.088261; after twenty updates it predicts
0.365380, above the target 0.200000. This one prediction shows the direction and size of the
change at that point. It does not establish whether the optimizer has converged.
At 200 updates with the same seed, the mean loss falls by more than two orders of magnitude:
# Change only the update budget to compare with the # twenty-step run. lake exe torchlean quickstart_mlp \ --device cpu --steps 200 --seed 2026
mean_loss(before training) = 0.495227 step 0: loss=0.250488 step 25: loss=0.586318 step 50: loss=0.847444 step 75: loss=0.003933 step 100: loss=0.023397 step 125: loss=0.069799 step 150: loss=0.000061 step 175: loss=0.029500 mean_loss(after training) = 0.002402 steps=200 arithmetic=native scalar=Float32 loss=0.495227 -> 0.002402 trained(heldout) = [0.228325]
The unchanged banner lines are omitted here. The per-sample losses vary from update to update:
0.250, 0.847, 0.003, 0.069, 0.000061, and 0.029. Each is measured on the sample used
at that step, so this column does not track a fixed objective on a fixed input.
The two mean_loss values evaluate the whole dataset. Even that average is not
guaranteed to decrease in expectation, without assumptions on the objective, sampling,
and step size. The held-out prediction has come back from 0.365380 to 0.228325
against a target of 0.200000. This is evidence of improvement at one held-out point; it supplies
neither a convergence theorem nor a generalization bound.
Training With Executable Binary32
The quickstart is arithmetic-polymorphic, so the same code runs on the executable binary32 model:
# Use the executable binary32 reference for the same # two-update workload. lake exe torchlean quickstart_mlp \ --device cpu --arithmetic ieee --steps 2 --seed 2026
The following transcript predates the FloatLib migration and retains its recorded scalar labels
and numerical results. Current .ieee execution uses FloatLib binary32.
mean_loss(before training) = 0.495227 step 0: loss=0.250488 mean_loss(after training) = 0.392821 steps=2 arithmetic=ieee scalar=IEEE32Exec loss=0.495227 -> 0.392821 trained(heldout) = [0.019031]
In the recorded comparison, the same two steps with --arithmetic native gave identical printed
numbers, down to the last digit of 0.392821 and [0.019031]. FloatLib binary32 is a
bit-level model of binary32 written in Lean, and the native path is hardware binary32, so equality
of these six-decimal displays alone does not establish bitwise agreement. The maintained scalar
conformance tests compare bits where that stronger claim is needed. The
floating-point chapter explains why that is expected for this operation mix
and where it stops being expected.
Not every command is arithmetic-polymorphic. Command-specific validation rejects an unsupported combination with an error, preserving the arithmetic requested by the caller.
Source:
SimpleMlpTrain.lean.
The synthetic target is chosen to fit the model family: it is a weighted combination of two
ReLU features, \operatorname{relu}(x_1+x_2) and
\operatorname{relu}(x_2-x_1), plus a constant. A width-eight hidden layer can represent those
features, so this exercise does not force the optimizer to approximate a smooth curve with an
undersized piecewise-linear model. The twenty-five training points form a five-by-five grid.
The held-out point lies between grid locations, making its prediction a separate observation
from the mean training loss.
Convolutional Model Training
Prepare the small CIFAR-10 fixture and run one CPU optimizer step:
# Prepare CIFAR arrays before running the cropped-image # classifier. python3 scripts/datasets/download_example_data.py --cifar10 lake exe torchlean cnn --device cpu --n-total 1 --steps 1 --seed 2026
[TorchLean] arithmetic: native binary32 [TorchLean] execution: eager [TorchLean] device: cpu cnn: CNN training (device=cpu) dataset size = 1 mean_loss(before training) = 2.348696 mean_loss(after training) = 2.343749 wrote TrainLog JSON: data/examples/cnn_trainlog.json steps=1 arithmetic=native scalar=Float32 loss=2.348696 -> 2.343749 cnn: ok
The starting loss can be compared with a reference value. Cross-entropy on ten classes with an
uninformative model should be about \ln 10:
-- A uniform prediction assigns probability one tenth to the
-- target class.
#eval Float.log 10.0
2.348696 is close to \ln 10, the loss of a uniform ten-class prediction. One sample's loss
does not establish that the full prediction vector is near uniform. Very small or large losses are
reasons to inspect the logits, labels, and objective, rather than proof of a particular bug.
dataset size = 1 counts the training dataset's minibatches. The current CNN command fixes the
batch size at one, loads a shuffled epoch of full batches, and crops each image to 8\times8.
Thus this particular run has one image in one batch. Increasing --n-total supplies more rows
and therefore more one-image batches; --steps independently sets how many optimizer updates
run. The input contract is [1, 3, 8, 8], and the output [1, 10] contains one class-logit row.
The command defines one public nn.models.CNN.Config, derives the checked input and output shapes
from that value, and passes it to nn.models.cnn. It then uses the shared NPY data boundary and
Trainer.new with trainer.train. --n-total 1 keeps this a check of that training path; it does
not establish useful CIFAR
accuracy. The reported change of about 0.005 records the effect of this particular update.
Source:
Cnn.lean.
Convolution changes the parameter-sharing pattern from the regression MLP. A small kernel is reused at spatial positions within each image, so nearby patches are processed by the same learned weights. The cropped input is chosen to exercise this spatial path and its gradients with a small workload. The one-step decrease establishes that this configured training path produced a changed loss; image classification quality would need held-out images and an accuracy evaluation, neither of which appears in this transcript.
Model Lowering And Interval Bounds
The predictions and gradients above concern particular inputs. To ask whether an output stays within a bound throughout a region, we need to account for every input in that region. Interval bound propagation does this by carrying an enclosure through the model. Start from an input box
x\in[\ell,u]
and push the box through the network, one operation at a time, keeping an enclosure at every step (Gowal et al., 2018).
For an affine layer y=Wx+b, the rule separates positive and negative weights, because a negative
coefficient maps the lower endpoint to the upper one:
\begin{aligned}
\ell'_i
&=\sum_j
\left(\max(W_{ij},0)\ell_j+\min(W_{ij},0)u_j\right)+b_i,\\
u'_i
&=\sum_j
\left(\max(W_{ij},0)u_j+\min(W_{ij},0)\ell_j\right)+b_i.
\end{aligned}
For ReLU the rule is simpler, because the function is monotone:
[\ell_i,u_i]
\longmapsto
[\max(0,\ell_i),\max(0,u_i)].
Interval Bound Calculation
The registered workflow uses a fixed two-layer model, and its constants are small enough to write
out in full. They come straight from
IBPWorkflow.lean:
-- Use the workflow’s fixed parameters and a radius around
-- its two-dimensional center.
def exW1 : Tensor Float [3, 2] :=
[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]
def exB1 : Tensor Float [3] := [0.1, 0.2, 0.3]
def exW2 : Tensor Float [1, 3] := [[0.7, 0.8, 0.9]]
def exB2 : Float := 0.4
def exCenter : Tensor Float [2] := [0.5, 0.8]
def exEps : Float := 0.1
One function implements the affine rule above, once, for any width:
/-- One affine row under interval arithmetic. A positive
weight pairs each endpoint with itself; a negative weight
swaps them. -/
def exRow {n : Nat} (w : Fin n → Float) (bias : Float)
(lo hi : Fin n → Float) : Float × Float :=
(List.finRange n).foldl
(fun acc j =>
let wj := w j
if wj ≥ 0.0 then
(acc.fst + wj * lo j, acc.snd + wj * hi j)
else
(acc.fst + wj * hi j, acc.snd + wj * lo j))
(bias, bias)
Every weight in this model is positive and every hidden lower bound will turn out to be positive too, so the ReLU layer acts as the identity and we can chain the two affine layers directly:
-- Propagate each hidden interval before combining the three
-- output contributions.
#eval show IO Unit from do
let xLo := fun j => Tensor.at exCenter (j, ()) - exEps
let xHi := fun j => Tensor.at exCenter (j, ()) + exEps
let hidden := (List.finRange 3).map fun i =>
exRow (fun j => Tensor.at exW1 (i, (j, ())))
(Tensor.at exB1 (i, ())) xLo xHi
for (box, i) in hidden.zipIdx do
IO.println s!"h{i + 1} = ({box.fst}, {box.snd})"
let y := exRow (fun i => Tensor.at exW2 (0, (i, ()))) exB2
(fun i => (hidden[i]!).fst) (fun i => (hidden[i]!).snd)
IO.println s!"y = ({y.fst}, {y.snd})"
IO.println s!"mid = {0.5 * (y.fst + y.snd)}"
All three hidden lower bounds are positive, which retroactively justifies dropping the ReLU. That
also permits a stronger calculation here: all weights are nonnegative, so the lower and upper
input corners attain the extrema. With these decimal constants interpreted as exact reals,
[1.904, 2.256] is the exact range. Stable ReLUs alone would not make multilayer IBP exact: mixed
signs can still lose dependencies. The binary64 calculation above illustrates the formula; it does
not use outward rounding and is not itself a certified enclosure. IBP is usually conservative;
here the nonnegative weights let the same pair of input corners attain all the intermediate
extrema.
For the first hidden unit, the lower input corner is (0.4,0.7), giving
0.1\cdot0.4+0.2\cdot0.7+0.1=0.28. The upper corner gives 0.34.
Repeating that calculation for all three rows produces the displayed hidden intervals. Their
midpoints are the hidden activations at the center because this whole region stays within one
linear piece of the ReLU network. If a hidden interval crossed zero, the active linear expression
could change within the box and that midpoint argument would need to be reconsidered.
IBP Workflow
# Lower the fixed model and propagate its input box with # native bound arithmetic. lake exe verify -- torchlean-ibp
=== TorchLean → IR → IBP (small MLP) workflow === [TorchLean] arithmetic: native binary32 lowered IR nodes: 18 output box lo: [1.904000] output box hi: [2.256001]
The printed lower bound agrees with the hand computation. The upper bound is slightly larger:
2.256001 against 2.256000. The workflow uses outward rounding to retain an enclosure.
A lower endpoint rounded upward or an upper endpoint rounded downward can exclude the exact
result. Interval operations must account for that error. The native path uses
HostFloat32.nextUp and HostFloat32.nextDown from
NN/MLTheory/CROWN to widen ordinary binary32 results by one
representable step. The following calculation measures the size of such a step near the final
upper endpoint; it does not count the widenings or derive their accumulated effect:
-- Measure representable spacing near the upper endpoint, in
-- billionths of a unit.
open NN.MLTheory.CROWN in
#eval show IO Unit from do
let top : Float32 := 2.256
let step := (HostFloat32.nextUp top).toFloat - top.toFloat
IO.println s!"one ulp = {1000000000.0 * step} nano"
IO.println s!"four ulps = {4000000000.0 * step} nano"
Four ulps at this magnitude are just under one millionth, the scale of the printed difference. The reference arithmetic gives:
# Select directed reference arithmetic for the same lowered # model and box. lake exe verify -- torchlean-ibp --arithmetic ieee
=== TorchLean → IR → IBP (small MLP) workflow === [TorchLean] arithmetic: IEEE-754 binary32 reference lowered IR nodes: 18 output box lo: [1.904000] output box hi: [2.256000]
FloatLib exposes directed addition and multiplication through ExecFloat.Binary.add and
ExecFloat.Binary.mul with .towardPositiveInfinity. On the finite real branch, these choose the
smallest representable upper result, rather than always widening a nearest-rounded result. The
interval rules also account for exceptional endpoints. The native path widens a nearest-rounded
result, while the
reference path computes directed rounding in Lean. The arithmetic banner identifies which policy
produced the box.
The lowering path is:
TorchLean model -> canonical IR -> supported-operation check -> interval propagation -> output box
lowered IR nodes: 18 counts the canonical IR after lowering, not the layers of the model. A
two-layer MLP expands into more nodes than one might guess because reshapes, broadcasts, and the
bias additions are all explicit at that level. The supported-operation check inspects these IR
operations.
The result applies to this supported IR fragment and numerical policy. A different model command needs its own lowering and supported-operation checks. IBP propagates intervals without auxiliary linear coefficients, losing dependencies between intermediate values. CROWN-style relaxations (Zhang et al., 2018) can retain more of that information and produce tighter bounds for suitable networks and relaxations; there is no unconditional precision ordering for arbitrary implementations. Related methods appear in Xu et al. (2020) and the verification chapter.
The other registered workflows, from lirpa-cnn through the two-stage Lyapunov
refinements, are listed by:
# Ask the verification dispatcher which workflows it # currently registers. lake exe verify -- list
The dispatcher is reachable from NN/Verification/CLI.lean.
An output box becomes useful only after it is compared with the application's property. For this scalar example, the lower endpoint can establish a threshold claim that lies below it, while a threshold inside the interval remains undecided by this enclosure alone. A wider box can therefore be numerically sound but insufficient for the requested property. This is the practical reason to inspect both supported operations and bound precision before treating successful propagation as successful verification.
Numerical Certificate Replay
We can also record intermediate ranges in a numerical certificate and replay it with a checker (Necula, 1997).
First, evaluate the same small MLP at the center of its input box and compare its native and reference gradients. We already have the weights and interval calculation needed to interpret the result. This comparison was recorded before the FloatLib migration:
# Compare the fixed MLP’s native and reference outputs and # reverse-mode gradients. lake exe torchlean float32_semantics
== Float32 semantics tutorial == Note: rounded-real binary32 is proof-only and is selected directly in theorem statements. [TorchLean] FP32: finite rounded-real proof model [TorchLean] IEEE32Exec: bit-level binary32 reference == Float32 (native runtime) == y = [2.080000] hiddenWeightGrad = [[0.350000, 0.560000], [0.400000, 0.640000], [0.450000, 0.720000]] hiddenBiasGrad = [0.700000, 0.800000, 0.900000] outputWeightGrad = [[0.310000, 0.670000, 1.030000]] outputBiasGrad = [1.000000] inputGrad = [0.760000, 1.000000] == IEEE32Exec == y = [2.080000] hiddenWeightGrad = [[0.350000, 0.560000], [0.400000, 0.640000], [0.450000, 0.720000]] hiddenBiasGrad = [0.700000, 0.800000, 0.900000] outputWeightGrad = [[0.310000, 0.670000, 1.030000]] outputBiasGrad = [1.000000] inputGrad = [0.760000, 1.000000] max_abs_diff(Float32 vs IEEE32Exec) = 0
Two lines of that transcript were rewrapped to fit this page; the runtime prints each gradient on one line.
y = 2.080000 is the midpoint of lab five's box, because the same parameters are evaluated at the
center of the same input region. Every gradient in the table is also checkable by hand. The loss
here is the output itself, so the cotangent is one, and the reverse pass reduces to three products:
\frac{\partial y}{\partial W_2}=h,
\qquad
\frac{\partial y}{\partial b_1}=W_2^{\top},
\qquad
\frac{\partial y}{\partial x}=W_2W_1.
-- Reconstruct the three gradient factors using the already
-- declared model constants.
#eval show IO Unit from do
let x := fun j => Tensor.at exCenter (j, ())
let w2 := fun i => Tensor.at exW2 (0, (i, ()))
let hidden := (List.finRange 3).map fun i =>
(List.finRange 2).foldl
(fun acc j => acc + Tensor.at exW1 (i, (j, ())) * x j)
(Tensor.at exB1 (i, ()))
let dInput := (List.finRange 2).map fun j =>
(List.finRange 3).foldl
(fun acc i =>
acc + w2 i * Tensor.at exW1 (i, (j, ())))
0.0
IO.println s!"h = {hidden}"
IO.println s!"W2 = {(List.finRange 3).map w2}"
IO.println s!"W2 W1 = {dInput}"
Those three lines are the outputWeightGrad, hiddenBiasGrad, and inputGrad rows of the
transcript, to every digit. The remaining row, hiddenWeightGrad, is the outer product
W_2^{\top}x^{\top}, and its first entry 0.7\times0.5=0.35 is right there in the table. An
autodiff implementation that gets all four of these right on a model this small is not proved
correct, but it has passed the test that catches transposed matrices and dropped chain-rule factors.
The final maximum-difference line says the hardware and the Lean bit-level model returned matching finite values for the outputs and gradients compared in this example. It does not inspect every intermediate rounding decision or distinguish the signs of zero. The command also contrasts the proof-only rounded-real model, which is selected in theorem statements and never executed, with those two executable paths; the distinction is developed in the floating-point chapter and has the same shape as the separation between the real and the float models in Boldo and Melquiond (2011).
The input-gradient row offers another connection to the interval calculation. Within this
stable ReLU region, changing the two inputs by (\delta_1,\delta_2) changes the exact-real
output by 0.76\delta_1+\delta_2. A radius of 0.1 in each coordinate therefore gives a
maximum deviation of 0.176, yielding 2.08\pm0.176. The derivative calculation and the
interval calculation meet here because this example is affine throughout the region; a local
gradient would not bound a region crossing an activation change.
Certificate Generation And Replay
# Generate certificates, replay concrete inputs, and reject # a tampered interval. lake exe torchlean numerical_certificate
TorchLean numerical runtime certificate ok base certificate ok base IEEE replay ok tampered range rejected ok two-layer MLP certificate ok two-layer MLP IEEE replay All numerical certificate checks passed.
The first two rows generate and replay a small scalar graph. The tampered-range row confirms that replay rejects an altered addition interval. The final two rows use the same machinery for a ten-node MLP. These checks exercise artifact generation and execution; they do not supply a proof of enclosure for every real input.
The checker and its proof layer are:
Certificate ranges are outward-rounded FloatLib binary32, and concrete inputs can be replayed in the
bit-level interpreter, which is what the two IEEE replay lines do. A native runtime is a separate
provider; the max_abs_diff = 0 above is evidence that it agreed on one trajectory, not a proof
that it always will.
Replaying a certificate also separates the producer's work from the checker's decision. The producer can supply proposed intermediate ranges, but acceptance depends on checking them against the graph and numerical operations. Altering a range is therefore a useful negative probe: the checker must reject an artifact that no longer satisfies its conditions even if its format still parses. The printed rejection concerns that altered artifact; the proof layer states the general conditions under which the checker can justify a numerical claim.
PyTorch Graph Import
The last lab imports PyTorch models into canonical IR and compares their outputs on deterministic inputs:
# Exercise external graph capture, parsing, and numerical # comparison on fixed probes. lake exe pytorch_export_check
== PyTorch nn.Module → TorchLean IR runtime check == generated Python float bit patterns and tiny epsilon: ok generated reference code and state-dict round trip: ok numerical parity: ok (TinyMLP) numerical parity: ok (TinyAffineLayerNorm) numerical parity: ok (TinyLayerNormEpsilon) numerical parity: ok (TinyBatchNormEpsilon) pytorch_export_check: ok
The four numerical probes cover an MLP, affine LayerNorm, and small-epsilon LayerNorm and BatchNorm. The generated-reference checks also exercise scalar expression emission and state-dict naming and orientation. These are small interoperability checks, not an exhaustive importer suite.
The import path is:
Python/PyTorch producer -> JSON value graph -> Lean parser -> canonical TorchLean IR -> WellShaped result or explicit rejection
parseGraph_wellShaped establishes the structural property of a successful parse. Numerical
agreement is checked separately: a graph with correct shapes can still compute the wrong function.
Neither result proves the Python exporter correct or establishes agreement on untested inputs.
Unsupported operations and malformed artifacts must be rejected at the capture or import boundary;
see the round-trip chapter for the supported schema and its limits.
Source: PyTorch.lean.
The normalization probes were chosen because seemingly small import details change the function: an affine scale and bias must retain their orientation, and epsilon belongs in a particular place in the normalization denominator. An imported graph can preserve every tensor shape while losing one of those details. Comparing deterministic outputs complements structural validation by making such semantic changes observable on the selected inputs. A new supported operator needs the same kind of account of its attributes, state, and numerical convention.
Building The Example Suite
Before changing maintained examples, build the curated umbrella:
# Elaborate the curated example targets before running their # entry points. lake build NNExamples
That checks the maintained Lean example targets. Elaboration is not execution, so runtime behavior is checked separately:
# Execute the retained suite; the final command runs the # CUDA-enabled build. lake exe nn_tests_suite lake -R -K cuda=true build nn_tests_suite lake env .lake/build/bin/nn_tests_suite
The maintained suite concentrates on numerical and native-boundary behavior: attention, convolution, pooling, reductions, gather and scatter, FFT, matrix multiplication, and buffer lifetime. For an example change, run that command with a small input and check its output; routine command and API refactors use temporary checks rather than a permanent all-command suite. Optional ALE/Pong and documentation rendering require their own external environment.
Runnable examples use public executable APIs such as Tensor.qr, Tensor.cholesky, nn.linear,
and Trainer.run. Spec.* references remain only where an example is explicitly demonstrating a
mathematical specification or theorem; no runtime example fabricates a result by evaluating a
placeholder in place of the public execution path. Many Spec definitions are executable reference
functions, but running them does not test the selected backend implementation.
Command implementations keep their local configuration vocabulary short because the namespace
already supplies the command name: Options for parsed flags and Preset for a named model
setup. Data prepared for one command likewise uses role names such as Splits or Evaluation,
rather than repeating the command name in every type.
Example Coverage
When reusing an example, carry its evidence with it. The value and gradient transcripts record
particular executions: mean(x^2) having the right gradient at (1,2,3) is a useful check of
autodiff, but a theorem must cover its stated domain. The reshape example illustrates a different
condition: the API requires a proof that the element counts agree, and Lean rejects the attempted
four-to-nine reshape because that proposition is false.
The interval workflow covers an input region within its supported IR fragment and numerical policy. Certificate replay checks an artifact against the checker's conditions, including a deliberately altered range that it rejects. The import checks combine a structural guarantee with output comparisons on fixed probes; correctness of the Python exporter remains a separate obligation.
The chapters that carry those claims further are verification, floating point, and autograd.