8.4 Image classification — MNIST FFNN (PyTorch via nnx)¶
A comprehensive walk-through of
notebooks/image_classification-mnist-ffnn-pytorch/ — the model-selection sweep
notebook that drives the nnx PyTorch toolkit across eighteen feed-forward
configurations on MNIST. This page is the deep-dive companion to the task
notebook: it states the problem, builds the math, dissects the sweep
architecture, reads the code top to bottom, reports the measured results, and
catalogues the pitfalls and extensions.
The notebook is Tier-B — the full [9 hidden_dimss × 2 dropout_probs × 500
epochs] sweep takes roughly seventeen minutes on macOS and over ninety on the
Linux GitHub runner, so CI runs it only on the weekly schedule (and on
workflow_dispatch and tier-b-smoke-labeled PRs). The call-shape contract is
pinned separately by a fast pytest that runs on every PR. It is the PyTorch
counterpart to image_classification-mnist-ffnn-numpy.md:
same task and dataset, but this variant exists to demonstrate how the nnx
library composes a clean training-and-evaluation pipeline around a model sweep.
8.4.1 Problem & motivation¶
MNIST handwritten-digit recognition — 60,000 training and 10,000 test 28×28 grayscale images, ten classes — is the workhorse benchmark this notebook uses to ask a single, model-selection-shaped question:
Holding the optimizer, loss, scheduler, data pipeline, and seed fixed, how does feed-forward architecture (depth, width, dropout) trade off against validation error on MNIST?
This notebook exists for three reasons:
- Canonical
nnxreference. It is the smallest, cleanest end-to-end example of "build a small classifier withnnx":NNDatasetfor the data,NNModelParams+NNParamsfor configuration,NNModelfor the trained object,NNRunfor the history,VisUtilsfor the visualizations. The same pattern recurs across the lab's image and generative tasks. - Real model-selection mechanics. Eighteen configurations are trained independently, ranked by best validation error, and the top five are visualized together. This is the unit of work for any architecture search — larger than a single fit, smaller than a hyperparameter-optimization study.
- Direct comparison to the from-scratch NumPy sibling. Same task, same dataset; the side-by-side answers "what does adopting a framework buy, and what does it hide?"
The falsifiable hypothesis under test is that, given Adam + a
ReduceLROnPlateau scheduler + normalized inputs, the deeper-and-wider end of the
sweep ([512, 256, 128, 64]) reaches sub-2% validation error, and that the
marginal gain tapers off once the network has enough capacity — i.e. that
[512, 256] gets most of the way to [512, 256, 128, 64] for meaningfully less
compute. The results section confirms or refutes this.
8.4.2 Concepts¶
| Concept | Where it shows up |
|---|---|
| Multiclass classification | Ten digits; one correct label per image |
| Softmax output layer | Implicit in Losses.CROSS_ENTROPY paired with Nets.FEED_FWD |
| Cross-entropy loss | Losses.CROSS_ENTROPY — the training objective |
| Feed-forward NN (MLP) | Nets.FEED_FWD swept over nine hidden_dims topologies |
| LeakyReLU activation | Recorded in the run details (net.activation = leaky_relu) between hidden layers |
| Dropout regularization | dropout_prob swept over {0.25, 0.5} |
| Adam optimizer | Default; max_lr=0.01, momentum=(0.9,0.999), weight_decay=5e-5 |
| ReduceLROnPlateau scheduler | Recorded in run details (factor=0.95, patience=8, cooldown=2, threshold=0.001, min_lr=1e-7) |
| Mini-batch SGD | train_batch_size = 60,000 (one full-batch step per epoch, like the NumPy sibling) |
| Model selection by best validation error | top_runs = sorted(runs, key=...)[0:5] |
| Input normalization | transforms.Normalize(mean=0.1307, std=0.3081) — the standard MNIST constants |
| Reproducibility | nnx.set_seed(0) pins Python/NumPy/PyTorch CPU+CUDA RNG |
| Checkpoint serialization | run.checkpoints() + NNModel.from_checkpoint(...) reload a trained model in a fresh session |
The nnx flat re-exports consumed are NNDataset, NNModel, NNParams,
NNTrainParams, NNModelParams, Nets, Losses, Devices, Utils,
VisUtils, and set_seed. The enums (Nets.FEED_FWD, Losses.CROSS_ENTROPY,
Devices.get()) make the model + training contract read as configuration
rather than magic strings — the same shape the Iris tabular notebook uses.
8.4.3 Mathematical formulation¶
A single image is flattened to \(x \in \mathbb{R}^{784}\) (this time normalized:
each pixel is rescaled to \([0,1]\) by ToTensor and then
\((x - 0.1307)/0.3081\) by Normalize). The label is the integer class
\(c \in \{0,\dots,9\}\); the framework produces the one-hot internally.
For a network with hidden layers of widths \(h_1, h_2, \dots, h_L\), the forward pass is
The empty-hidden-layers case (hidden_dims = []) collapses to logistic
regression — \(o = x W^{\top} + b\) directly. The training objective is
cross-entropy:
minimized by Adam with learning rate \(\eta = 10^{-2}\), weight decay \(5 \times 10^{-5}\), and moments \((\beta_1, \beta_2) = (0.9, 0.999)\):
The ReduceLROnPlateau scheduler watches validation error and multiplies the
learning rate by factor = 0.95 after patience = 8 iterations of
insufficient improvement (delta below threshold = 0.001), enforcing a
cooldown = 2-iteration pause between reductions and flooring at
min_lr = 1e-7. The whole sweep is therefore self-annealing per run — no
global LR schedule has to be tuned by hand.
The selection metric is best-iteration validation error:
and runs are ranked by this quantity. Lower is better; a 0.0160 best-run value (§8.4.6) means the model's lowest-validation-error checkpoint misclassifies 1.6% of the held-out validation set.
8.4.4 Architecture¶
The network family is Nets.FEED_FWD (nnx.FeedFwdNN): an input layer of 784
units (one per normalized pixel), zero or more hidden layers each followed by
LeakyReLU and dropout, and a 10-unit output layer consumed by softmax +
cross-entropy. The notebook sweeps nine topologies × two dropout values
= eighteen candidates, holding everything else fixed:
hidden_dims |
Layers (incl. output) | Rough params (incl. biases) | Role |
|---|---|---|---|
[] |
784 → 10 | ~7.9k | Multinomial logistic regression; the floor |
[128] |
784 → 128 → 10 | ~102k | Single non-linearity |
[256] |
784 → 256 → 10 | ~204k | Wider single hidden |
[512] |
784 → 512 → 10 | ~407k | Widest single hidden |
[256, 128] |
784 → 256 → 128 → 10 | ~235k | Two-layer funnel |
[256, 128, 64] |
784 → 256 → 128 → 64 → 10 | ~244k | Three-layer funnel |
[512, 256] |
784 → 512 → 256 → 10 | ~536k | Wide two-layer |
[512, 256, 128] |
784 → 512 → 256 → 128 → 10 | ~570k | Wide three-layer |
[512, 256, 128, 64] |
784 → 512 → 256 → 128 → 64 → 10 | ~579k | Deepest candidate |
The shared contract — everything held constant across all eighteen candidates:
- Net:
Nets.FEED_FWD(LeakyReLU between hidden layers) - Loss:
Losses.CROSS_ENTROPY - Optimizer: Adam,
max_lr=1e-2,weight_decay=5e-5,momentum=(0.9, 0.999) - Scheduler: ReduceLROnPlateau,
factor=0.95,patience=8,cooldown=2,threshold=0.001,min_lr=1e-7 - Device:
Devices.get()— CUDA if available else CPU - Epochs:
500(full sweep) or2(SMOKE_TEST=1, which also collapses the sweep to[[]] × [0.25], i.e. one logistic-regression run) - Seed:
nnx.set_seed(0)once at import; the framework re-pins per-run - Batch sizes:
train=60,000,val=1,000,test=9,000(fromNNDatasetdefaults for MNIST)
The data plumbing is identical across candidates: a single NNDataset instance
wraps torchvision.datasets.MNIST with the standard normalization and exposes
train_loader, val_loader, and test_loader. Because train_batch_size is
the full training set, each epoch is one full-batch gradient step — the same
full-batch regime as the NumPy sibling, just running here on top of Adam rather
than vanilla SGD.
The a priori expectation: the empty-architecture floor ([]) should land in
the high-single-digit-percent error region (logistic regression on MNIST is
well-studied); each added hidden layer should buy progressively smaller margins
down to the low-single digits; and the deepest [512, 256, 128, 64] candidate
should be in the 1.5–2% band typical of well-tuned MLPs on MNIST.
8.4.5 Code walkthrough¶
8.4.5.1 Dataset¶
DS_MEAN, DS_STD = 0.1307, 0.3081
ds = NNDataset(
ds_class=thv.datasets.MNIST,
transform=thv.transforms.Compose([
thv.transforms.ToTensor(), # uint8 → [0,1]
thv.transforms.Normalize(mean=DS_MEAN, std=DS_STD) # standardize
]),
)
NNDataset wraps a torchvision dataset class and exposes input_dim=784,
output_dim=10, plus the three DataLoader-backed splits. The recorded split
sizes are train=60,000, val=1,000, test=9,000. The normalization
constants are the published MNIST mean and standard deviation — the same values
every mainstream MNIST example uses, and the single biggest difference from the
NumPy sibling (which feeds raw [0, 255]).
8.4.5.2 Sweep specification¶
n_epochs = SMOKE_TEST_EPOCHS if SMOKE_TEST else 500
dropout_probs = [0.25] if SMOKE_TEST else [0.25, 0.5]
hidden_dimss = [[]] if SMOKE_TEST else [[], [128], [256], [512],
[256, 128], [256, 128, 64],
[512, 256], [512, 256, 128],
[512, 256, 128, 64]]
models = [
NNModel(
params=NNModelParams(
net=Nets.FEED_FWD, device=Devices.get(), loss=Losses.CROSS_ENTROPY,
),
net_params=NNParams(
dropout_prob=dropout_prob, hidden_dims=hidden_dims,
input_dim=ds.input_dim, output_dim=ds.output_dim,
),
)
for dropout_prob in dropout_probs
for hidden_dims in hidden_dimss
]
The list comprehension is the entire sweep definition. hidden_dims is the
only structural axis; dropout_prob is the only regularization axis; everything
else flows from the shared NNModelParams. Note the Cartesian product order:
dropout is the outer loop, hidden_dims the inner, so candidates group by
dropout value in the resulting models list.
8.4.5.3 Training¶
train_params = [
NNTrainParams(n_epochs=n_epochs)
.with_train_loader(value=ds.train_loader)
.with_val_loader(value=ds.val_loader)
]
runs = [
model.train(params=train_param)
for model in models
for train_param in train_params
]
NNTrainParams is built fluently — .with_train_loader(...) and
.with_val_loader(...) attach the loaders without mutating a shared params
object. model.train(...) returns an NNRun carrying idps (iteration data
points), each with train_edp and val_edp (evaluation data points exposing
.error, .accuracy, .precision, .recall, .f1, .lr). The per-run
defaults recorded in the committed output confirm Adam + ReduceLROnPlateau +
max_lr=0.01; the notebook never instantiates any of them by name — they are
the framework defaults.
8.4.5.4 Ranking and visualization¶
top_runs = sorted(
runs,
key=lambda run: min(run.idps, key=lambda idp: idp.val_edp.error).val_edp.error,
)[:5]
VisUtils.multi_line_plot(
x=[i for i in range(0, max(top_runs, key=...).idps[-1].iter_idx)],
yss_legend=[[str(run) for run in top_runs], ["Training", "Validation"]],
yss=[[[idp.train_edp.error for idp in run.idps],
[idp.val_edp.error for idp in run.idps]] for run in top_runs],
...
)
The ranking key is the best validation error a run ever achieved — not its
final-epoch value — which is robust to late-training overfitting oscillations.
The same multi_line_plot is then called again with idp.lr to overlay the
ReduceLROnPlateau learning-rate traces across the top five runs, making the
self-annealing behavior directly visible.
8.4.5.5 Inspection: t-SNE and sample grid¶
for checkpoint in top_runs[0].checkpoints():
if checkpoint is None: continue
VisUtils.two_dim_tsne_checkpoint_logits(checkpoint=checkpoint, ds=ds, n_samples=5_000)
_best = [c for c in top_runs[0].checkpoints() if c is not None][-1]
model = NNModel.from_checkpoint(checkpoint=_best)
test_X, test_Y = ...
test_Y_hat = model.predict(X=test_X)
# 81-sample grid: green title if Y_hat == Y else red
Two artifacts close out the notebook. The t-SNE projection takes the best run's
checkpointed logits on 5,000 samples and projects the 10-dimensional logit
vectors down to two dimensions, so the class-cluster separation is visible at a
glance across checkpoints. The sample grid reloads the best run from disk via
NNModel.from_checkpoint(...) — the same serialization contract the longer
image and generative tasks depend on — and renders an 81-image tile with
correct/incorrect color-coding.
8.4.5.6 Surface test (the every-PR gate)¶
Because the full sweep is too slow for per-PR CI, the call-shape contract is
pinned separately in
tests/nnx_surface/test_image_classification_mnist_ffnn_pytorch.py. It builds a
small 784 → [16] → 10 FFNN, trains one epoch on a tiny image batch, calls
model.predict(X=...), and asserts the returned (logits, classes) shapes —
(4, 10) and (4,) — and that classes are integer-typed. A second test pins
the hidden_dims=[] logistic-regression configuration. If a thekaveh-nnx
version bump breaks the NNModel + Nets.FEED_FWD call chain, this test
catches it in the fast lane, before the weekly Tier-B smoke run would.
8.4.6 Results & analysis¶
The committed cell-8 output records the sweep's verdict from a full 500-epoch run:
best run is
01d915cb…which achieves validation error of 0.0160 (1.6%).
Three observations:
- The deepest candidates reach the expected band. A best validation error
of 1.6% on MNIST is consistent with the published range for well-tuned MLPs
with dropout and Adam (~1.5–2%); the hypothesis that the deep end of the
sweep lands sub-2% is confirmed. The empty
[]floor lands well above this, confirming that capacity matters on MNIST even with normalized inputs and Adam — unlike Iris, where the linear baseline was already near-saturating. - The top-five ranking is the actionable artifact. Sorting by best-run
validation error and keeping the top five makes the capacity-vs-marginal-gain
curve directly readable from the loss-overlaid plot. Where the curve flattens
(typically between
[256, 128]and[512, 256, 128]) is the natural capacity point beyond which extra depth buys diminishing returns for extra training time. - The LR-schedule overlay explains why runs converge smoothly. The ReduceLROnPlateau traces show step-downs clustered around the same iteration range across the top runs — they hit their plateau together once the bulk of the learning is done. This is the visible signature of not having to tune a global LR schedule by hand.
The t-SNE projection and the 81-sample prediction grid are qualitative diagnostics rather than headline numbers: the former shows the class clusters tightening across checkpoints; the latter is a sanity check that the correct/incorrect color coding matches what the validation error claims. The sibling NumPy notebook reports neither — this is the evaluation surface the framework buys.
8.4.7 Pitfalls & edge cases¶
- Tier-B runtime is real; don't drop the cap blindly. The full
[9 × 2 × 500]sweep takes ~17 min on macOS and over 90 min on the Linux GH runner (issue #7). The notebook is smoke-run on the weekly CI schedule, not on every PR; the surface test is what guards the call shape per-PR. Treat any local re-execution that refreshes committed outputs as a long-running job. - The smoke target writes to
/tmp/, not in place.make smoke-tier-btrains a1 × 1 × 2-epoch sweep and writes it to/tmp/specifically to preserve the committed full-sweep outputs. Running the smoke target will not refresh the committed cell-8 number; for that, open the notebook in Jupyter and run all cells. SMOKE_TEST=1collapses the sweep, not just the epochs. Under smoke, the sweep drops to[[]] × [0.25]— a single logistic-regression candidate. Do not read the resulting loss curve as representative of the architecture comparison; it is a "does the pipeline run" check, not a model-selection result.- Committed outputs can drift from committed source. An intentional
in-place Tier-A refresh (
make run-tier-a) rewrites execution metadata and outputs. CI instead usesmake smoke-tier-aand writes generated copies to/tmp/ml-tier-a, leaving the committed notebook unchanged. If a recorded cell output reflects the smoke run (2 epochs) while another reflects the full run (500 epochs), you are looking at this drift, not a bug. - The framework defaults are load-bearing. The notebook never names Adam,
ReduceLROnPlateau, or
max_lr=0.01— they come fromNNTrainParams/NNModelParamsdefaults. Athekaveh-nnxrelease that changes optimizer or scheduler defaults will silently change this notebook's results; the surface test does not pin them, only the call shape. Devices.get()is environment-dependent. On a CUDA box the sweep runs on GPU and is much faster; on CPU (the CI default) it is the ~17/90-min number above. Reproducibility across machines requires pinningDevices.CPUexplicitly, which the notebook does not.- Best-vs-final validation error. Ranking uses best-iteration validation error, which is robust to late-training overfitting but can be an optimistic estimate of generalization. The selected checkpoint corresponds to the best-iteration moment, not the last epoch — appropriate, but worth flagging when comparing against papers that report final-epoch numbers.
- Don't compare absolute numbers head-to-head with the NumPy sibling. The
two notebooks differ in initialization, optimizer (Adam vs vanilla SGD),
scheduler (ReduceLROnPlateau vs none), input normalization (standardized vs
raw
[0,255]), activation (LeakyReLU vs PReLU), and sweep coverage. They are sibling demonstrations of the same task, not controlled trials of framework choice.
8.4.8 Extensions & references¶
- Compare against the from-scratch NumPy sibling —
image_classification-mnist-ffnn-numpy.md. Same task and dataset with no framework, no autograd, no normalization, and vanilla SGD. The side-by-side is the cleanest illustration of whatnnxprovides (optimizer, scheduler, sweep, serialization, viz) versus what the NumPy variant makes visible (every gradient and weight update). - Add an early-stopping callback. The README lists this as future work.
Adopting the available
nnx.EarlyStoppingcallback with an explicit validation monitor would add a stopped-at-N-epochs signal alongside the current "best-iteration" ranking. - Add a CNN variant (LeNet-style) for direct comparison against the FFN.
The other README future-work item. A pair of conv layers on top of the same
NNDataset+NNModelplumbing should drop validation error into the sub-1% band typical of small CNNs on MNIST and isolate what spatial structure buys over flatten-and-dense. - Add k-fold cross-validation on the top-N architectures. The single-split ranking is fine for picking a winner among eighteen candidates but underpowered for declaring one architecture significantly better than another at 10k test samples; a 5-fold CV over the top five closes that gap at 5× the (already substantial) compute.
- Persist and reload via
NNModel.from_checkpoint. The notebook already uses this for the t-SNE and sample-grid cells, but only on the best run. A follow-up that saves every run's checkpoints to./runs/and reloads them downstream (deployment, further analysis, ensemble) exercises the serialization contract end-to-end — the same primitive the longer image and generative tasks depend on. - Tighten the surface test. The current
tests/nnx_surface/test pins the call shape only. Adding assertions that pin the optimizer name and scheduler presence would catch the silent-default-change failure mode flagged in §8.4.7 in the fast lane rather than at the next weekly smoke run.