PyTorch MNIST

01 — Introduction

A census, a pile of forms, and 500 teenagers

Before there was a benchmark, there was a paperwork problem.

In the late 1980s the US Census Bureau was drowning in handwriting. Every form came back filled in by hand, and every one of them had to be read by a person before it became a number in a table. So the Bureau went to the National Institute of Standards and Technology with a request: build us a collection of handwritten characters large enough to judge whether a machine could plausibly do the reading instead.

NIST spent the next few years gathering samples. Thousands of Census field staff scattered across the country filled out standardised forms. Then, wanting a harder test drawn from a different population, NIST collected from 500 high-school students in Maryland.

The two groups did not write alike. People who fill in forms professionally produce neat, consistent, almost typographic digits. Teenagers produce something else. And in the original release one population sat entirely in the training set while the other sat entirely in the test set — which meant your reported accuracy depended less on your algorithm than on which group you happened to train against.

So the dataset was rebuilt. Thirty thousand images from each group went into training, five thousand from each into testing, and the writers were carefully segregated so that nobody's handwriting appeared on both sides of the split. Each digit was size-normalised and centred inside a 28 × 28 box, the smoothing along the way turning hard black-and-white scans into grayscale.

That rebuilt version is MNIST — Modified NIST — and it is what you will train on here. Sixty thousand training images, ten thousand test images, ten classes, and a question a human answers in a fraction of a second without noticing they answered it: which digit is this?

What this post builds

By the end you will have a classifier that answers that question correctly about 97.7% of the time. The model itself is almost anticlimactic — two linear layers and an activation function, a dozen lines of code. That is rather the point. The interesting part of PyTorch is not the architecture; it is the machinery that surrounds it, and the small number of places where getting the order wrong costs you silently.

So the route runs through the workflow first and arrives at the classifier last:

Assumed knowledge You should be comfortable with Python and have met the idea of a neural network — layers, weights, an activation function. Everything PyTorch-specific is built up from scratch here.

02 — Data Ingestion

Why your data arrives in batches

The first thing PyTorch asks you to change is not your model. It's how the data reaches it.

Imagine the delivery company from module one has grown. Instead of ten deliveries you now have 100,000 records, and the obvious move is to read them all in first and worry about the model later:

load_everything.py
# Try to load all delivery data into memory
all_distances = []
all_times = []
for i in range(100000):
    distance, time = load_delivery_record(i)
    all_distances.append(distance)
    all_times.append(time)

Every one of those pieces has to live in your computer's RAM at the same time. At 100,000 records you might be fine. Add a few million rows, or bolt on weather data, traffic patterns and driver info, and the machine runs out of memory and crashes in no time.

So you load the data piece by piece instead. The practical unit is a batch — a smaller, manageable chunk of the full dataset. But batching alone isn't enough. Before data is ready for training it also has to be processed, formatted and served in a shape the model understands, and that is exactly the job of PyTorch's three core data utilities: transforms, Dataset and DataLoader.

The principle Load only what you need, when you need it. The same tooling that handles thousands of delivery records handles millions of images without changing shape.

03 — Data Prep

The three tools that feed the model

Each one owns a distinct part of the journey from disk to model, and they are designed to hand off to one another.

transforms — clean each sample as it loads

transforms
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((mean,), (std,))
])

Transforms are operations that run on each data point as it is loaded. Compose simply means "do the following things, in this order." The two you will meet first are there for one reason: neural networks are surprisingly picky, and they train much better when every input is a small number centred near zero.

  • ToTensor() — converts the data to a PyTorch tensor and scales it into the range 0 to 1. Divide [2, 4, 6, 8, 10] by its max and you get tensor([.2, .4, .6, .8, 1]).
  • Normalize() — shifts those values so they centre on zero, then scales them by the standard deviation: tensor([-1.41, -0.70, 0, .70, 1.41]).

Dataset — know where a sample lives and how to fetch it

dataset
# PyTorch has many pre-built datasets
dataset = SomeDataset('./data', train=True, download=True, transform=transform)

first_item = dataset[0]          # Just gets one

A dataset fetches each sample from disk when it is asked to, rather than preloading everything in one shot — that is the secret to handling datasets far bigger than memory. It knows four things: where your data lives on disk, how to load a specific sample, how many samples there are in total, and how to apply your transforms to each one on the way out.

The arguments read exactly as they sound. './data' is where the files are stored locally; train=True picks the training split rather than the test split; download=True fetches the data if it isn't already there; and you pull out a single sample by indexing.

DataLoader — serve batches on demand

dataloader
dataset_loader = DataLoader(dataset, batch_size=32, shuffle=True)

The loader is the part that makes training on large datasets possible: it requests one batch at a time from the dataset. batch_size sets how many samples arrive at once, and shuffle mixes the order, which helps the model learn more effectively during training.

All three together

pipeline.py
# 1. Define your transforms
transform = SomeTransform()      # ToTensor + Normalize

# 2. Create Datasets with transforms
train_dataset = YourDataset('./data', train=True, transform=transform)

# 3. Create DataLoaders
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)

# 4. Training
for batch_idx, (data, labels) in enumerate(train_loader):
    # Data arrives in batches, already transformed
    output = model(data)

04 — Modeling

Two ways to define a model

nn.Sequential works beautifully. The other pattern does exactly the same thing with more control — and you will see it everywhere in PyTorch code.

model.py
model = nn.Sequential(            class ExampleModel(nn.Module):
    nn.Linear(1, 20),                 def __init__(self):
    nn.ReLU(),                            super().__init__()
    nn.Linear(20, 1)                      self.layer_1 = nn.Linear(1, 20)
)                                         self.relu    = nn.ReLU()
                                          self.layer_2 = nn.Linear(20, 1)

                                      def forward(self, x):
                                          x = self.layer_1(x)
                                          x = self.relu(x)
                                          x = self.layer_2(x)
                                          return x

Every PyTorch module class splits into two halves. __init__ defines which layers exist — a little like gathering your tools. forward describes the path the data actually takes through them. Sequential does the same ordering, just written in a different style.

Call the model, never .forward()

calling
model = ExampleModel()

output = model(data)             # ✔
output = model.forward(data)     # ✘

You wrote a forward method, so calling it directly feels natural. But note that Sequential is itself a subclass of Module with its own forward — and you never called that one by hand either. When you call the model and pass in the data, PyTorch does more than run your method: it makes internal checks, tracks the necessary math, and sets things up for updating the model later. Calling forward yourself skips all of that essential bookkeeping.

And super().__init__() is not boilerplate

skipping super()
class ExampleModel(nn.Module):
    def __init__(self):
        # super().__init__()   # What if you skip this?
        self.fc1 = nn.Linear(784, 128)

# AttributeError: cannot assign parameter before Module.__init__() call
model = ExampleModel()

PyTorch needs somewhere to track all the learnable parameters — the weights and biases you will be updating during training. super().__init__() is what creates that tracking system. Without it, PyTorch has nowhere to register your layers, and it says so immediately.

05 — Training

The training loop, and why the order matters

Five lines. Written in the wrong order they still run, produce no error, and quietly fail to teach the model anything.

train.py
for epoch in range(epochs):
    optimizer.zero_grad()                    # 1. Reset the optimizer
    outputs = model(X)                       # 2. Make predictions
    loss = loss_function(outputs, y)         # 3. How bad was this guess?
    loss.backward()                          # 4. Calculate adjustments
    optimizer.step()                         # 5. Update the model

zero_grad clears out the old calculations, backward figures out the improvements, and step applies them. This standard sequence is the core of most PyTorch training loops, and the failure mode when you rearrange it is silence — PyTorch will not throw an error, but your model will not learn properly.

If you write it this way What actually happens
step() before backward() Trying to update before calculating what to update — the model adjusts itself using the previous batch's numbers, not this one's
zero_grad() after backward() You just threw away all the work backward did
zero_grad() outside the loop Calculations pile up batch on batch, and the model starts making huge adjustments where it should be making tiny ones
Habit worth forming Same five lines, same order, every time. It is one of the few places in PyTorch where being creative costs you silently.

06 — Evaluation

Checking whether it actually learned

Evaluation means testing on data the model has not previously seen and was not trained on. Testing on the training set is like giving a student the same exam twice — they might just have memorised the answers.

evaluate.py
model.eval()          # Set evaluation mode (NOT "evaluate my model"!)

with torch.no_grad():  # Disable gradient tracking
    correct = 0
    total = 0
    for images, labels in test_loader:
        outputs = model(images)
        _, predicted = torch.max(outputs, 1)   # class with highest score
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

    accuracy = 100 * correct / total
    print(f'Accuracy: {accuracy}%')

model.train()         # Set the model back to training mode
  • model.eval() — despite the name, this does not evaluate anything. It puts the model into evaluation mode, which is both cheaper computationally and necessary because some layers behave differently during training and evaluation.
  • torch.no_grad() — switches off the extra tracking PyTorch does during training. Leave it on and PyTorch keeps storing details it does not need, wasting memory and sometimes crashing the program mid-validation.

For a classification task the score itself is straightforward: count how often the model gets the class right and divide by the total attempts. 9,500 correct out of 10,000 is 95% accuracy. And if you are heading back into training afterwards, remember to switch the mode back.

07 — Measure

Loss: putting a number on how wrong you are

Three lines carry the whole of training, and they run in a fixed sequence — measure, diagnose, update.

measure · diagnose · update
loss = loss_function(outputs, targets)   → measure
loss.backward()                          → diagnose
optimizer.step()                         → update

First you measure how wrong the predictions are — one number that sums up all the mistakes. Then backward diagnoses the problem, examining how each weight contributed to that error and by how much. Finally step adjusts each parameter, with the biggest corrections going to the weights that caused the biggest problems.

Every loss function does the same basic job: compare predictions against the true answers and return a number. The higher the number, the more wrong you are.

Mean squared error, for predicting numbers

Back on the delivery data, suppose the model predicts 6 minutes when the truth was 4, and 3 minutes when the truth was 5. Subtract target from prediction and you have the error. Average the raw errors, though, and something absurd happens:

RealPredictedDifferenceDifference²
46−24
53+24
Average04

Zero. Perfect performance, apparently, from two predictions that were both wrong. Squaring the differences removes the minus signs so mistakes stop cancelling each other out — and it has a second effect worth having. Being off by 10 minutes scores 100 while being off by 1 scores 1, so a ten-minute miss is a hundred times worse than a one-minute miss.

MSELoss
loss_function = nn.MSELoss()
loss = loss_function(predictions, targets)

So MSE helps in two ways: it makes sure every mistake counts, and it punishes bigger errors more than small ones. It is the right choice whenever you are predicting a continuous value — distances, temperatures, prices.

Cross-entropy, for predicting categories

CrossEntropyLoss
loss_function = nn.CrossEntropyLoss()
loss = loss_function(outputs, labels)

Here the model isn't picking a single answer — it outputs a confidence score across every possible class. For MNIST that is ten numbers per image, one probability per digit, all adding up to 100%:

output
Digit:  0     1     2     3     4     5     6     7     8     9
Conf:   0.05  0.02  0.08  0.70  0.03  0.04  0.02  0.03  0.02  0.01

And here is the key idea: cross-entropy punishes overconfident wrong answers. If the model is 95% sure an image is a 7 when it is really a 3, the loss is very high. If it is only 55% sure, it is still wrong but less brazen about it, and the loss is smaller. You want a model that is confident about right answers and unsure about wrong ones — cross-entropy is what shapes that behaviour.

Don't mix them up MSE on a classification task works, but poorly — training is slow and potentially unstable. CrossEntropyLoss on a regression task will probably just break, because it expects probability distributions rather than continuous values. And never compare their raw numbers: "my MSE is 0.08 but cross-entropy gives 2.3, so which is better?" compares two entirely different scales. All that matters is that the number goes down.

Choosing, and what else is out there

  • MSE — predicting a number, such as a temperature, price or distance.
  • CrossEntropyLoss — predicting a category, like a digit, animal or word.
the wider menu
nn.MSELoss()            # average squared difference — regression
nn.CrossEntropyLoss()   # multi-class classification (1 out of many)
nn.L1Loss()             # average absolute difference — less outlier-sensitive
nn.BCEWithLogitsLoss()  # binary classification (yes/no, true/false)
nn.NLLLoss()            # multi-class when you're using LogSoftmax
nn.SmoothL1Loss()       # balances MSE and L1 (also called Huber loss)
nn.KLDivLoss()          # difference between two probability distributions

There are plenty more, each suited to a specific situation, but MSE and cross-entropy cover a great deal of territory in deep learning.

08 — Diagnose & Update

Gradients and optimizers

Loss tells you that something is wrong. Gradients tell you which weights are responsible.

Each neuron takes its inputs, multiplies each by a weight, adds them up with a bias, and passes the result through an activation function — z = W1*x1 + W2*x2 + W3*x3 + b. Even a small network involves a startling number of those:

LayerWeightsBiases
784 inputs → 128 hidden784 × 128 = 100,352128
128 hidden → 10 outputs128 × 10 = 1,28010
Total101,770 trainable parameters

backward works through all of them like a detective, asking each weight and bias the same question: how much did you contribute to the loss? The answers are the gradients.

GradientReading
Positive · +2.5Increasing this weight makes the loss worse — decrease it
Negative · −2.5Increasing this weight would have helped — increase it
Large · +10, −8.5Very influential
Small · +.001, −.0005Barely mattered
Common misconception backward does not update the weights. It only calculates gradients. The actual updates happen later, when you call optimizer.step().

Downhill, one step at a time

Minimising loss is like standing on a hillside trying to reach the bottom of a valley. The gradient tells you the slope where you're standing — which way is up, which way is down. You head downhill, toward lower loss. Hence gradient descent.

SGD
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

Stochastic gradient descent has a simple strategy: negative gradient, increase the weight; positive gradient, decrease it; big gradient, big change; small gradient, small change. It does not subtract the gradient directly, though — it scales it by the learning rate first, so a gradient of 0.5 with lr=0.01 moves the weight by 0.005. That scaling is what makes or breaks training:

  • Tiny learning rate — tiny steps; it will take forever to reach the bottom.
  • Good learning rate — steady progress all the way down.
  • Huge learning rate — giant leaps that bounce back and forth, overshooting the minimum.
Adam
# Smaller learning rate
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
optimizer = torch.optim.SGD(model.parameters(),  lr=0.01)

SGD works well, but smarter optimizers adapt to each weight individually. Adam is one of those — something like an assistant who knows which weights need big adjustments and which need fine-tuning. It has become a popular first choice because it is reliable, flexible and often faster than the alternatives. One word of caution: do not copy the learning rate from SGD when you switch to Adam, because it is tuned completely differently and your loss might explode. Beyond these two sit RMSprop, Adagrad, AdamW, NAdam and a dozen more, but for most projects SGD and Adam have you covered.

Which finally explains zero_grad

Every time you call backward, PyTorch adds the new gradients to whatever is already sitting there. Skip zero_grad and you are no longer diagnosing this batch — you are accumulating the diagnoses of every batch so far, and they keep piling up incorrectly until training breaks. That accumulation is deliberate, because it enables advanced tricks like gradient accumulation and custom training schedules, but for ordinary work you want a clean slate at the top of every loop.

09 — Hardware

Device management

Every tensor and every model lives on a device. PyTorch will not move things around for you automatically, and if they are not all in the same place your code may simply refuse to run.

the error you will meet
RuntimeError: Expected all tensors to be on the same device

A CPU is built for general-purpose work and runs operations sequentially. A GPU runs them in parallel, and during training that can be 10 to 15 times faster. If your system has one, you almost always want to use it — you just have to place things by hand.

device.py
# 1. Is there an accelerator?
torch.cuda.is_available()        # True → GPU available

# 2. Pick a device — the safe default you'll see everywhere
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 3. Move the model once, when you create it
model = MyModel().to(device)

# 4. Move each batch, every iteration
for inputs, targets in dataloader:
    inputs = inputs.to(device)
    targets = targets.to(device)

cuda refers to NVIDIA GPUs and their toolkit of the same name. There are other options — MPS for Apple Silicon, for instance — but CUDA is the most commonly used.

Finding out where something lives

checking
# For tensors
print(inputs.device)

# For models
print(next(model.parameters()).device)

Models themselves aren't on a device — their parameters are, so you check one parameter to learn where they all are. If you are still seeing device errors after that, check your targets and your model's outputs too.

The mistake everyone makes once .to() does not change the tensor in place — it creates a new one. Any time you use it, assign the result to a variable you will actually use.
.to(device)
x.to(device)        # looks like it moves x — but it doesn't
x = x.to(device)    # this does

The whole loop, placed correctly

train_on_gpu.py
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MyModel().to(device)
optimizer = optim.Adam(model.parameters())
loss_function = nn.CrossEntropyLoss()

for inputs, targets in dataloader:
    inputs = inputs.to(device)
    targets = targets.to(device)
    optimizer.zero_grad()
    outputs = model(inputs)
    loss = loss_function(outputs, targets)
    loss.backward()
    optimizer.step()

Three steps, and they are the foundation of every training script you will write: choose your device up front, move the model once, move the data every batch.

And when the GPU runs out of room

GPU memory is limited. If your model and batch size need more than it has, you get CUDA out of memory. Small batches make training slow; batches that are too large crash it. For many systems a batch size between 32 and 64 is a good starting point, though it depends on your hardware and architecture. If you see a memory error, lower the batch size first — it is the most common fix.

10 — Putting it together

An MNIST classifier, start to finish

60,000 training images, 10,000 test images, each one 28 × 28 pixels of grayscale handwriting, centred, digits 0 through 9.

imports
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader

TorchVision is PyTorch's computer vision library. It ships with the popular datasets, MNIST among them, plus the image-processing tools.

Step 1 — preprocessing

transforms
# Data preprocessing
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])

ToTensor scales the pixels from 0–255 down to 0–1, and Normalize shifts and scales those values so they centre on zero. Those two odd-looking numbers are the mean and standard deviation of the entire MNIST training set — normalising every image by the same pair keeps the data consistent, which helps the model learn faster.

Step 2 — the datasets

datasets
# Load MNIST dataset
train_dataset = torchvision.datasets.MNIST(
    root='./data', train=True, download=True, transform=transform)

test_dataset = torchvision.datasets.MNIST(
    root='./data', train=False, download=True, transform=transform)

The test set is almost identical — the single change is train=False, which gives you the 10,000 test images instead of the 60,000 training ones. Same transforms, same storage location; TorchVision handles the downloading and organising.

Step 3 — the loaders, and why only one of them shuffles

loaders
# Create data loaders
train_loader = DataLoader(train_dataset, batch_size=64,   shuffle=True)
test_loader  = DataLoader(test_dataset,  batch_size=1000, shuffle=False)

Training runs 64 images per batch, reshuffled every epoch so the model meets them in a different random order each time. Testing uses much larger batches — there are no gradients to compute, so there is no reason to go in small.

The asymmetry is worth pausing on. Datasets often arrive organised by class, so without shuffling your model might see 6,000 zeros in a row before it meets a single one. It could learn an unintended pattern — early batches are zeros, late batches are nines — instead of learning what actually makes a zero look like a zero. Shuffling mixes everything so each batch carries variety. At test time the model has finished learning and you are only checking whether it recognises digits, so the order stops mattering.

Step 4 — the network

MNISTClassifier
class MNISTClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.flatten = nn.Flatten()
        self.layers = nn.Sequential(
            nn.Linear(784, 128),
            nn.ReLU(),
            nn.Linear(128, 10)
        )

    def forward(self, x):
        x = self.flatten(x)
        x = self.layers(x)
        return x

Flatten is the new piece, and it exists because of a shape mismatch. A single MNIST image arrives as a [1, 28, 28] tensor — one channel (grayscale, a single brightness value per pixel) by 28 by 28. Batch it up and PyTorch adds a dimension, so at batch_size=64 the data reaches your model as [64, 1, 28, 28].

Linear layers expect flat vectors: one long row of numbers per image, not a two-dimensional grid. Flatten reshapes each 28 × 28 image into 784 values in a row — 28 × 28 = 784 — turning [64, 1, 28, 28] into [64, 784]. Leave it out and the image data hits the linear layer and raises a shape mismatch error.

After that the stack is short: 784 pixel values become 128 hidden features, ReLU keeps the positives and zeroes the negatives, and 128 features become 10 outputs — one per digit class.

Step 5 — device, loss, optimizer

setup
# Check for GPU
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using {device}')

# Initialize model and move to device
model = MNISTClassifier().to(device)

# Loss function and optimizer
loss_function = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

Cross-entropy because the model picks one class out of many, which is exactly the job of choosing a digit from 0 to 9. Adam because it adapts its learning rate as it goes — larger adjustments early, when gradients are noisy, and smaller corrections later as training stabilises.

Step 6 — one epoch of training

train_epoch
def train_epoch(model, train_loader, loss_function, optimizer, device):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0

    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = loss_function(output, target)
        loss.backward()
        optimizer.step()

        # Track progress
        running_loss += loss.item()
        _, predicted = output.max(1)
        total += target.size(0)
        correct += predicted.eq(target).sum().item()

Five inputs — the model, the loader, the loss function, the optimizer, and the device everything should run on. model.train() puts the model into training mode, and three counters track the epoch: running_loss accumulates loss values, correct counts predictions matching the true labels, total counts the samples seen. Inside the loop it is the familiar sequence, bookended by moving the batch onto the device and recording what happened. output.max(1) is what tells you which digit class scored highest.

With 60,000 images at batch size 64, an epoch is about 938 batches — so printing every hundredth gives you roughly nine progress lines per pass.

Step 7 — evaluation, then ten epochs

evaluate + loop
def evaluate(model, test_loader, device):
    model.eval()
    correct = 0
    total = 0

    with torch.no_grad():
        for inputs, targets in test_loader:
            inputs, targets = inputs.to(device), targets.to(device)
            outputs = model(inputs)
            _, predicted = outputs.max(1)
            total += targets.size(0)
            correct += predicted.eq(targets).sum().item()

    return 100. * correct / total


# Training loop
num_epochs = 10
for epoch in range(num_epochs):
    print(f'\nEpoch: {epoch+1}')
    train_epoch(model, train_loader, loss_function, optimizer, device)
    accuracy = evaluate(model, test_loader, device)
    print(f'Test Accuracy: {accuracy:.2f}%')

The evaluation function is the training function with everything unnecessary removed: no optimizer, no loss tracking, no weight updates. Just how many did you get right.

Ten epochs means ten full passes over the training set, and it is not mere repetition — each pass refines the model's sense of what makes a 2 different from a 7. Evaluating on the test set after every epoch is what tells you whether it is learning generalisable patterns or simply memorising.

11 — Results

What the training actually looked like

Two views of the same ten epochs: the curves, and the digits themselves.

The loss and accuracy history is the standard instrument panel. The left panel tracks loss per epoch for both splits, the right tracks accuracy, and the wide panel underneath plots the loss of every single one of the 9,380 batches — the noise the epoch averages smooth away.

Training history. Train loss falls from 0.254 to 0.020 and train accuracy climbs from 92.5% to 99.3%. Test accuracy, though, flattens out around 97.5% after the fourth epoch — and test loss bottoms out at 0.081 on epoch 8 before drifting back up to 0.094 while train loss keeps falling. That gap opening up is the model beginning to memorise the training set. It is the clearest argument for evaluating on held-out data every epoch rather than trusting the training numbers.

The curves tell you the model improved. They do not show you what improving looks like. For that, ten test digits — one per class — were pulled out before training started and re-predicted after every epoch, so you can watch the labels change. Press play, or drag the slider.

Actual vs predicted, epoch by epoch. Green means the prediction matches the true label, red means it doesn't, and the percentage is how confident the model was. Before training the model scores 15.99% and its confidences hover around 12–19% — barely distinguishable from picking at random across ten classes. One epoch later it has nine of the ten right and most confidences are above 95%. The stubborn cases are the interesting ones: the 5 is read as a 6 at 68% confidence after epoch one and is not corrected until epoch four, and even at epoch ten the 3 is only held at 70%. Ambiguous handwriting stays ambiguous.

Where it lands 97.69% test accuracy after ten epochs, from a model that is two linear layers and a ReLU. Notice that accuracy stops improving well before the tenth epoch — often a sign the model is done learning for now, and that you may not have needed all ten.

This post is inspired by the PyTorch Fundamentals course on DeepLearning.AI, taught by Laurence Moroney. The explanations and worked examples follow the course material; the charts are from my own training runs.