In the past lessons you built, piece by piece, everything you need: convolution, padding, stride, pooling, parameter counting, the training loop. Now you put them together and ask the question that really matters: is a convolutional network better than a dense one? The answer you expect is yes, and it is the one you would find repeated everywhere. The answer you will find by measuring, with several seeds and a fair comparison, is more nuanced, and for that very reason instructive: it shows you where the advantage of the convolutional structure really lies and how much the rest of the pipeline matters (the augmentation, the data, the evaluation).
We will use the scikit-learn Digits dataset again (1797 8×8 digits), for three reasons: it is small and runs in seconds on any machine, you already know it from the lesson on training a dense network in NumPy and PyTorch, and the results are comparable with those. You will write a CNN as a PyTorch nn.Module class, a reusable training loop, and compare four configurations over five different seeds. You will also learn to save a model, to evaluate it in conditions different from those of training, and to recognize the typical traps of an image pipeline. You need the shapes and parameters of a CNN from the previous lesson.
How the numbers were obtained: all the listings were run twice, with identical output, on CPU (no GPU), with PyTorch 2.12.0, Python 3.14, scikit-learn 1.8.0 and torch.set_num_threads(2); the whole script takes about 5.5 minutes on the machine used (a single measurement). With another version of PyTorch or another number of threads the figures may change in the last decimal digit: that is why the comparison is made on the large differences relative to the variability across seeds, not on thousandths.
The PyTorch vocabulary for networks
- Tensor: a multidimensional array (as in NumPy) that can live on CPU or GPU and keeps track of the operations in order to compute gradients.
nn.Module: the base class of every model. A subclass declares the layers in the constructor (__init__) and describes the forward pass in theforwardmethod. PyTorch discovers by itself the parameters contained in the layers assigned as attributes, and returns them withmodel.parameters().state_dict: the name → tensor dictionary of all the parameters of a model; it is the format used to save and reload a trained model.model.train()/model.eval(): switches that change the behaviour of some layers (dropout, batch normalization) between training and evaluation.torch.no_grad(): a context that turns off gradient computation, to evaluate faster and with less memory.- Data augmentation: generating new training examples by applying transformations that do not change the label (shifting, rotating, cropping an image). The network always sees different variants and learns not to depend on them.
The six terms fall into two groups. nn.Module, state_dict and tensor describe how the model is made and how it is stored; train(), eval() and no_grad() describe in which mode it is used, and they are the ones that get forgotten. In this network there is no dropout and no batch normalization, so eval() would not change the result; it is written anyway out of habit, because adding one layer is enough to make it decisive (see the lesson on dropout and batch normalization). The augmentation, on the other hand, is pure preprocessing and does not depend on the framework.
The drawing shows the idea with a real digit 3 from the dataset: the original, the same shifted one pixel to the right, the same shifted right and down. For a person it is always a 3; for a dense model they are three very different vectors of 64 numbers, because every pixel has changed value.
The data as tensors for a CNN
A convolutional network wants tensors (N, C, H, W): a channel dimension is needed even for a greyscale image (C = 1). The dataset supplies the digits already flattened into 64 values from 0 to 16: we reshape them to (1, 8, 8) and normalize them with the mean and standard deviation of the training set alone (a single scalar value for the whole training set, because the pixels all have the same physical meaning).
A note on the code: the Python blocks of this article are to be run in order, in a single script or notebook. Each reuses the imports and variables defined in the previous blocks.
import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.set_num_threads(2) # the figures in this article were obtained with 2 threads
X, y = load_digits(return_X_y=True)
X_tmp, X_te, y_tmp, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)
X_tr, X_va, y_tr, y_va = train_test_split(X_tmp, y_tmp, test_size=0.25, stratify=y_tmp, random_state=0)
media, dev = X_tr.mean(), X_tr.std() # a single scalar, from the training set only
def a_tensore(a):
return torch.tensor(((a - media) / dev).reshape(-1, 1, 8, 8), dtype=torch.float32) # (N, 1, 8, 8)
T_tr, T_va, T_te = a_tensore(X_tr), a_tensore(X_va), a_tensore(X_te)
L_tr, L_va, L_te = [torch.tensor(a, dtype=torch.long) for a in (y_tr, y_va, y_te)] # labels: 'long' integers
print("training shape:", tuple(T_tr.shape), " validation:", tuple(T_va.shape), " test:", tuple(T_te.shape))
print("mean %.3f and deviation %.3f from the training set; label type: %s" % (media, dev, L_tr.dtype))
training shape: (1077, 1, 8, 8) validation: (360, 1, 8, 8) test: (360, 1, 8, 8)
mean 4.885 and deviation 6.016 from the training set; label type: torch.int64
What the code does. The stratified 60/20/20 split is the same as in the lesson on the dense network, so the results are comparable. a_tensore normalizes with the training statistics (leakage avoided here too) and uses reshape(-1, 1, 8, 8) to obtain (N, 1, 8, 8): the -1 leaves it to NumPy to compute N. The labels are integers of type long (64 bit), required by CrossEntropyLoss.
The network as an nn.Module
We build a scaled-down version of LeNet suited to 8×8 images: two convolution-ReLU-pooling blocks with “same” padding, then two dense layers. With 8×8 at the input, the first pooling brings it to 4×4 and the second to 2×2: the flatten produces 16·2·2 = 64 values.
class CNN(nn.Module):
def __init__(self):
super().__init__() # initializes the base class: mandatory
self.conv = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # (1,8,8) -> (8,8,8) -> (8,4,4)
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2)) # (8,4,4) -> (16,4,4) -> (16,2,2)
self.fc = nn.Sequential(nn.Flatten(), nn.Linear(16 * 2 * 2, 32), nn.ReLU(), nn.Linear(32, 10))
def forward(self, x):
return self.fc(self.conv(x)) # logits: no final softmax
def MLP(): # the dense model of the previous lesson, for comparison
return nn.Sequential(nn.Flatten(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 10))
modello = CNN()
print("CNN parameters: %d MLP parameters: %d" % (sum(p.numel() for p in modello.parameters()), sum(p.numel() for p in MLP().parameters())))
z = torch.zeros(1, 1, 8, 8)
print("shape after the convolutional part:", tuple(modello.conv(z).shape), " output shape:", tuple(modello(z).shape))
print("registered parameters:", [n for n, _ in modello.named_parameters()])
CNN parameters: 3658 MLP parameters: 2410
shape after the convolutional part: (1, 16, 2, 2) output shape: (1, 10)
registered parameters: ['conv.0.weight', 'conv.0.bias', 'conv.3.weight', 'conv.3.bias', 'fc.1.weight', 'fc.1.bias', 'fc.3.weight', 'fc.3.bias']
What the code does. A subclass of nn.Module declares the layers in the constructor and composes them in forward; the call modello(x) invokes forward. Having put the layers as attributes (self.conv, self.fc) “registers” them: named_parameters() lists them with names like conv.0.weight, conv.0.bias, ..., and this is what the optimizer and state_dict will use. The parameter count gives 3,658 for the CNN and 2,410 for the dense model: the CNN has about 50% more parameters, because we chose 8 and 16 filters. They are not models identical in capacity, and for that reason the comparison will only be indicative; the lesson is in comparing the qualitative differences, not in the name of the winner on a decimal.
A reusable training loop
We write a general function: it receives the model, the seed and a flag for the augmentation, trains with Adam and early stopping on the validation set, and restores the best weights. The augmentation is a random translation of −1, 0 or +1 pixel in each direction, applied to the training set only and to each mini-batch (the validation and test sets must never be modified).
def trasla_batch(xb):
out = torch.zeros_like(xb)
dx, dy = torch.randint(-1, 2, (len(xb),)), torch.randint(-1, 2, (len(xb),)) # shift for each example
for a in (-1, 0, 1):
for b in (-1, 0, 1):
maschera = (dx == a) & (dy == b)
if maschera.any():
out[maschera] = torch.roll(xb[maschera], shifts=(a, b), dims=(2, 3))
return out
def addestra(modello, seme, augmentation=False, epoche=200, pazienza=10, lr=0.003):
torch.manual_seed(seme)
ott = torch.optim.Adam(modello.parameters(), lr=lr)
perdita_fn = nn.CrossEntropyLoss()
loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(T_tr, L_tr), batch_size=32, shuffle=True)
migliore = (np.inf, None, 0)
for epoca in range(epoche):
modello.train()
for xb, yb in loader:
if augmentation:
xb = trasla_batch(xb)
ott.zero_grad()
perdita_fn(modello(xb), yb).backward()
ott.step()
modello.eval()
with torch.no_grad():
perdita_val = perdita_fn(modello(T_va), L_va).item()
if perdita_val < migliore[0] - 1e-4:
migliore = (perdita_val, {k: v.clone() for k, v in modello.state_dict().items()}, epoca)
elif epoca - migliore[2] >= pazienza:
break
modello.load_state_dict(migliore[1])
return epoca + 1
def accuratezza(modello, T, L):
modello.eval()
with torch.no_grad():
return (modello(T).argmax(dim=1) == L).float().mean().item()
def trasla(T, a, b): # shifts a whole set (for the robustness test)
return torch.roll(T, shifts=(a, b), dims=(2, 3))
torch.manual_seed(0)
cnn = CNN()
epoche_usate = addestra(cnn, seme=0)
print("CNN (seed 0): epochs run %d accuracy training %.3f validation %.3f test %.3f" % (
epoche_usate, accuratezza(cnn, T_tr, L_tr), accuratezza(cnn, T_va, L_va), accuratezza(cnn, T_te, L_te)))
CNN (seed 0): epochs run 36 accuracy training 1.000 validation 0.969 test 0.969
What the code does. trasla_batch chooses for each example a horizontal and a vertical shift in {−1, 0, 1} and applies them with torch.roll (which shifts the pixels and makes them “re-enter” from the opposite side: for digits, which have almost empty margins, it is a reasonable approximation of a translation). The function groups the examples by shift to avoid a loop over each single image. addestra is the loop of the previous lesson with three differences: the model is an nn.Module; the augmentation is applied only if requested and only to the training batches; the batch size is 32. Note that the validation set used for early stopping is not augmented: it measures the error on the real distribution. With seed 0 the CNN stops after 36 epochs with training at 1.000, validation at 0.969 and test at 0.969. The functions accuratezza and trasla serve for the evaluation: the second shifts a whole set of images, and we will use it to measure robustness.
The fair comparison: four configurations, five seeds
A single run says nothing about a difference of half a point: the initial weights and the order of the batches change the result by a few examples. So we repeat each configuration with five seeds and report mean and standard deviation. We evaluate the test set in three conditions: original images, shifted one pixel to the right, shifted one pixel to the right and down. The last two simulate a small change in the data distribution (a digit not perfectly centred, a new scanner) that is frequent in reality.
configurazioni = (("MLP", MLP, False), ("CNN", CNN, False), ("MLP + augmentation", MLP, True), ("CNN + augmentation", CNN, True))
print("%-20s %-16s %-16s %-16s %s" % ("model", "original test", "shifted test (0,+1)", "shifted (+1,+1)", "epochs"))
for nome, costruttore, augmentation in configurazioni:
righe = []
for seme in range(5):
torch.manual_seed(seme)
m = costruttore()
epoche_eseguite = addestra(m, seme, augmentation)
righe.append((accuratezza(m, T_te, L_te), accuratezza(m, trasla(T_te, 0, 1), L_te), accuratezza(m, trasla(T_te, 1, 1), L_te), epoche_eseguite))
r = np.array(righe)
print("%-20s %.3f +- %.3f %.3f +- %.3f %.3f +- %.3f %.0f" % (
(nome,) + tuple(v for k in range(3) for v in (r[:, k].mean(), r[:, k].std())) + (r[:, 3].mean(),)))
model original test shifted test (0,+1) shifted (+1,+1) epochs
MLP 0.969 +- 0.004 0.411 +- 0.015 0.133 +- 0.008 29
CNN 0.966 +- 0.004 0.582 +- 0.045 0.289 +- 0.013 34
MLP + augmentation 0.930 +- 0.012 0.916 +- 0.019 0.868 +- 0.024 71
CNN + augmentation 0.967 +- 0.006 0.949 +- 0.014 0.930 +- 0.014 54
What the code does. For each of the four configurations it trains five models with different seeds, evaluates each on the original test set and on the shifted test sets, and prints mean and standard deviation. Read the table carefully, because it contains three distinct messages.
1. On the original test set the CNN is not better than the dense model. The accuracy is 0.969 for the MLP and 0.966 for the CNN, with deviations of 0.004: the difference of three thousandths is within the noise across seeds. It is not an error in the code: with tiny images (8×8, one pixel is already 12% of the side) and carefully centred data, the dense model has everything it needs. The advantage of convolutions shows with larger images, where the number of parameters of a dense layer explodes, and when the images are not aligned. Whoever presents a CNN as “always better” is omitting the comparison with a dense baseline.
2. Shifting by a single pixel is enough to put the dense model in trouble. With a shift of (0, +1) the MLP falls from 0.969 to 0.411; with (+1, +1) to 0.133, practically chance. The CNN without augmentation does better but not well: 0.582 and 0.289, with a deviation of 0.045 in the first case, that is, a strong dependence on the seed. Pooling offers a partial invariance (as you measured in the previous lesson), not sufficient for systematic shifts with a network trained only on centred images. This is the typical overfitting to the training distribution: the validation accuracy stays high because the validation set has the same characteristics as the training set.
3. The augmentation changes the picture. The CNN trained with random shifts of ±1 pixel keeps 0.967 on the original test set and obtains 0.949 and 0.930 on the shifted test sets: it loses very little and gains enormously in robustness. On the hardest case, (+1, +1), it beats the MLP with augmentation (0.868) by six points, with deviations of 0.014 and 0.024: a clear difference. For the MLP, on the other hand, the augmentation helps robustness (0.916 and 0.868) but costs on the original test set (it falls to 0.930) and requires more epochs: a dense model has to learn each translation separately, while the convolution shares the weights across positions, and the augmentation leverages this structure. It is the combination, the right structure plus varied data, that works.
A caution in the name of honesty: the shifted test sets use the same family of transformations (one-pixel translations) with which the augmentation trained, so they measure how well the models learned that variation, not a general robustness. The number of epochs grows (from about 34 to 54 for the CNN) because the task becomes harder and the early stopping kicks in later.
How to read a table of results without being fooled
A useful practical rule: with five seeds, the mean has an uncertainty (standard error) of about the standard deviation divided by √5, that is, deviation/2.2. For the MLP on the original test set the deviation is 0.004, so the mean 0.969 is uncertain by about ±0.002; for the CNN the deviation is 0.004 and the uncertainty ±0.002. The difference of 0.003 between the means is of the order of one standard error: not decisive. On the shifts, instead, the differences (0.41 against 0.95 between the MLP without augmentation and the CNN with augmentation) are tens of times larger than the deviations: no doubt. The criterion to keep in mind: a difference makes sense only if it clearly exceeds the variability across seeds. If your configurations differ less, either you repeat with more seeds, or you stop saying that one wins.
There is a second reading. The robustness test we built (shifting by one pixel) is one form of distribution change among the many possible: rotations, brightness changes, noise, blur, different backgrounds. A model robust to one is not automatically robust to the others. The augmentation makes you robust to what you have decided to simulate; to find out what the model is not robust to you have to try, and in serious projects it is an activity that takes as much time as training.
How to decide which augmentation to use
The transformations must be chosen with domain knowledge, not at random. Three questions help. Does it preserve the label? A one-pixel translation does not change a digit; a horizontal flip changes a 2 into something that is no longer a 2, and a 3 into an ε. For a photo of a cat the flip is legitimate; for a text it is not. Does it mirror real variations? If in production the digits come from a scanner with small rotations, adding rotations of a few degrees helps; adding rotations of 90° does not.
How much? Too much augmentation makes the task impossible and lowers the accuracy on the original test set: we saw it for the MLP, which with ±1 pixel loses almost four points on the original test set because its capacity is small and it has to share it among all the variants. The CNN, thanks to the shared weights, has a structural advantage. The correct procedure is always the same: you try one transformation at a time, measure on the validation set and on the robustness test, and keep what helps.
Saving, reloading and using the model for inference
A trained model is useful if you can reuse it. In PyTorch you save the state_dict (the parameters only), not the whole object: to reload it you rebuild the class and load the dictionary. Here we use an in-memory buffer instead of a file, so as not to depend on the filesystem, but the procedure with torch.save(modello.state_dict(), "modello.pt") and torch.load("modello.pt") is identical.
import io
torch.manual_seed(0)
finale = CNN()
addestra(finale, seme=0, augmentation=True)
buffer = io.BytesIO()
torch.save(finale.state_dict(), buffer) # saves ONLY the parameters
buffer.seek(0)
ricaricato = CNN() # rebuilds the same architecture
ricaricato.load_state_dict(torch.load(buffer))
ricaricato.eval() # inference mode
with torch.no_grad():
logit = ricaricato(T_te[:5]) # a batch of 5 images
probabilita = torch.softmax(logit, dim=1) # softmax is applied outside the model, at inference time
print("predictions:", logit.argmax(dim=1).tolist(), " true labels:", L_te[:5].tolist())
print("maximum confidence for each one: ", np.round(probabilita.max(dim=1).values.numpy(), 3))
print("identical outputs before and after saving:", torch.allclose(finale.eval()(T_te[:5]), logit))
predictions: [7, 6, 3, 7, 7] true labels: [7, 6, 3, 7, 7]
maximum confidence for each one: [0.997 0.955 0.998 0.99 0.913]
identical outputs before and after saving: True
What the code does. It trains a final model (with augmentation), saves its parameters in a buffer, rebuilds a new CNN() object with the same architecture and loads the weights into it. eval() turns off the behaviours typical of training. At inference the model produces logits; to obtain probabilities softmax is applied outside the model (CrossEntropyLoss applied it internally during training). The final check confirms that the outputs are identical before and after saving: it is a check worth doing every time you implement the loading of a model, because a slightly differently rebuilt architecture would give a shape error, or (worse) weights loaded in the wrong order.
The traps of an image pipeline
try:
CNN()(torch.zeros(5, 8, 8)) # the channel dimension is missing
except RuntimeError as e:
print("images without a channel:", str(e)[:85])
try:
nn.CrossEntropyLoss()(CNN()(T_tr[:4]), L_tr[:4].float()) # float labels instead of long
except (RuntimeError, TypeError) as e:
print("float labels: ", str(e)[:85])
images without a channel: Given groups=1, weight of size [8, 1, 3, 3], expected input[1, 5, 8, 8] to have 1 cha
float labels: expected target dtype to be Long or Byte, but got Float
What the code does. It reproduces two errors you will really meet. The first: passing a stack of images (N, H, W) instead of (N, C, H, W). PyTorch interprets the tensor (5, 8, 8) as a single image with 5 channels, and the message (“expected input[1, 5, 8, 8] to have 1 channels”) tells you exactly this: the network expected a single channel. The second: passing float labels to the classification CrossEntropyLoss, which wants integers for class indices: the message says “expected target dtype to be Long or Byte, but got Float”.
From the lab to production: a checklist
- Version the model and the preprocessing together: the mean 4.885 and the deviation 6.016 are as much a part of the model as the weights. If they change, the predictions change without any error being signalled.
- Validate the input: shape, type, range of values. An image on a 0-255 scale instead of 0-16 does not produce an error: it produces meaningless probabilities with high confidence.
- Do not trust the confidence: the maximum softmax probability is often high even for inputs that the model has never seen and does not understand (in the example, confidences between 0.913 and 0.998 on five digits, all right). It is not a calibrated measure of uncertainty.
- Monitor the data in production: if the distribution changes (like our one-pixel shift), the accuracy drops without your knowing. You need checks on simple statistics (mean and deviation of the pixels, distribution of the predicted classes) and a sample of labelled examples periodically.
- Record the conditions: seed, library versions, hyperparameters, data split. Without them, the result is not reproducible.
The five items have a common root: the model you take to production is not just the set of weights, but weights, preprocessing, shape and type of the input, and training conditions. If one of these things changes silently, the network keeps answering with confidence, and that is why the checks must be placed where the error shows up as a plausible number.
Three exercises
Exercise 1. Add small rotations (±10°) to the augmentation function using torchvision.transforms.functional.rotate or, if you want no dependencies, wider shifts (±2 pixels). Measure accuracy on the original test set and on the shifted test sets. Does anything change on the original test set?
Exercise 2. Build a different robustness test, for example by adding Gaussian noise to the pixels (with deviation 0.3 in the normalized units). Does the translation augmentation help here too? (Probably little: each augmentation makes you robust only to the variation it simulates.)
Exercise 3. Repeat the comparison between MLP and CNN with 10 seeds instead of 5, and compute the standard error of each mean. Does the difference on the original test set now turn out to be significant?
Trade-offs and real-world traps
- Presenting a single seed: a difference of 0.003 (like the one between MLP and CNN on the original test set) does not let you say that one model is better. Report mean and deviation over several seeds.
- Augmentation applied to validation or test: they are distortions designed for training; applying them to the evaluation measures something else, and if you have the test set randomly augmented, the result is not reproducible.
- Augmentation that changes the label: a 180° rotation turns a 6 into a 9. The transformations must preserve the meaning: for digits, small translations and rotations, not flips.
- Evaluating only on the training distribution: the accuracy on the original test set was identical for two models that, when shifted by one pixel, have very different performance (0.41 against 0.95). Always prepare robustness tests that mirror how the data change in production.
- Inconsistent normalization: the same mean and deviation as in training must be used in production. Save these numbers together with the model.
- Forgetting
model.eval()before inference (with dropout or batch norm it changes the result) andtorch.no_grad()(it wastes memory). - Saving the whole object with pickle instead of the
state_dict: it depends on the exact structure of the code and breaks at the first refactoring. - Reproducibility: fixing
torch.manual_seedhelps (here, on CPU, two runs gave identical outputs), but on GPU some operations are non-deterministic; for serious comparisons, repeat with several seeds instead of chasing a single exact result. - A bigger model is not the solution: here the decisive factor was the augmentation, not the capacity.
The nine items fall into three families: statistics (a single seed, tests that do not mirror production), data discipline (wrong augmentation, inconsistent normalization) and code discipline (eval(), pickle, reproducibility). The last item is the synthetic lesson of the comparison: faced with a weak result, before enlarging the model it is worth asking whether the data and the evaluation are the right ones.
Frequently asked questions
When does a CNN really beat a dense network? When the images are large enough that a dense layer would have millions of parameters, when objects appear in different positions and when local features need to be reused. On small, centred, low-resolution data, like these, the difference shrinks or disappears.
Isn't augmentation “cheating”? No: it builds into the training procedure a true piece of knowledge about the problem (a digit shifted by one pixel is the same digit). It is legitimate as long as the transformations are consistent with the real data and as long as the evaluation stays on non-augmented data.
Why is the validation data not augmented? Because the validation must measure the quality on the kind of data you will meet. If you want to measure robustness, it is done with dedicated tests, like the shifted ones above.
Can I repeat the experiment with MNIST or with larger images? Yes, and I advise you to: with images of 28×28 or more, the number of parameters of a dense network grows rapidly and the gap with a CNN at equal parameters tends to open, in favour of the CNN. The code of this lesson needs few changes (the 8×8 dimensions, the number of input channels, the value of nn.Linear after the flatten, to be recomputed with a trial pass); libraries like torchvision download the datasets and offer ready-made augmentation transformations. The caution stays the same: more seeds, a comparison with a dense baseline, a robustness test.
How many epochs with the augmentation? More than without: the task is harder. The early stopping handles it by itself.
What you can do now
You can write a CNN as an nn.Module, train it with early stopping and compare it with a dense model over five seeds. You can read the result: on the original test set the CNN does not beat the dense model (0.966 against 0.969, within the noise), but under one-pixel shifts the dense model falls to 0.41 and 0.13 and the CNN with augmentation stays at 0.95 and 0.93. You can save and reload the parameters alone with state_dict, check that the outputs do not change and recognize the typical traps (missing channels, labels of the wrong type, augmentation on the evaluation, a single seed). In the next lesson you tackle the architecture that made networks with tens and hundreds of layers trainable: residual connections, 1×1 convolution and Inception blocks.
Review checklist before trusting an image pipeline
The most expensive errors in an image pipeline crash nothing: they produce plausible and wrong numbers. Before believing a result, check these points, all of which arise from the traps seen in this lesson. (1) Mode: model.train() during training and model.eval() with torch.no_grad() in evaluation and inference; forgetting eval() leaves dropout and batch statistics active and makes the predictions vary from one call to the next. (2) Same preparation of the data in training and in production: normalization, size, channel order and numeric type must coincide, and the normalization values must be computed on the training set alone and saved together with the model.
(3) Real separation between training and test: if the same image, or almost identical variants of it, appear on both sides, the metric measures memory and not generalization. (4) Seeds and repetitions: a comparison between two configurations based on a single run is not a comparison, because the initialization noise can be as large as the difference; report mean and deviation over several seeds. (5) Tensor shapes: print the shapes at every step the first time you build the model, because an error of one dimension propagates silently through the operations that accept broadcasting. (6) Device: model and data must be on the same device, and reloading a model saved on GPU in an environment without a GPU requires you to indicate where to map the weights.
Other lessons in the series that link to this one: Reading a CNN: feature maps, global pooling and U-Net, Transfer learning: reusing a pretrained CNN.
Sources
- PyTorch Tutorials: Saving and Loading Models - saving and reloading the parameters alone with
state_dict, page reached (HTTP 200), consulted on 2026-11-12 - PyTorch: torch.nn.CrossEntropyLoss - loss that takes logits and class indices (hence the requirement of integer labels), page reached, consulted on 2026-11-12
- PyTorch: torch.nn.Module - base class of the models,
parameters(),train(),eval(), page reached, consulted on 2026-11-12 - scikit-learn: sklearn.datasets.load_digits - 1797 samples, 8×8 images, integers from 0 to 16, read on the page, consulted on 2026-11-12
- Y. LeCun, L. Bottou, Y. Bengio, P. Haffner, Gradient-based learning applied to document recognition, Proceedings of the IEEE, 86(11), 1998 - the LeNet-5 architecture of which the network is a reduced version (not re-read online for this work)
Commenti
Caricamento commenti...
Sign in to comment