Think back to the last time you tried to get started with machine learning. You searched for a tutorial, found one that opened with a diagram of circles joined by lines, scrolled past three paragraphs about partial derivatives, reached a section titled "The Mathematics of Backpropagation", and quietly closed the tab. Nothing was wrong with you. That tutorial simply chose to explain the engine before letting you drive the car. This one does the opposite. Over the next twenty minutes you will build a working neural network, watch it learn to read handwritten digits, and see it answer correctly on images it has never encountered. The explanations arrive next to the code, exactly when you need them and not a minute earlier.
Highlights
- A neural network is a large arithmetic expression with adjustable numbers inside it, and training is the automated process of nudging those numbers until the answers come out right.
- Nobody writes the rule that a seven has a flat top, because the network derives rules like that from examples alone, which is the line that separates learning from programming.
- The training loop is five operations long and identical in every PyTorch project, so learning it once on 109,386 parameters transfers directly to models a thousand times larger.
- ReLU looks trivial and is load bearing, since without it a three layer network collapses mathematically into a single layer and can only separate classes with straight lines.
- Loss and accuracy measure different things, which is why loss keeps falling long after accuracy has flattened out.
- Gradients accumulate rather than replace in PyTorch, a deliberate design that enables large batch training on small hardware and quietly corrupts results when you forget it.
- Most beginner failures are shape errors rather than mathematical ones, and reading tensor shapes aloud catches the majority of them before you run anything.
Before you begin
This is part one of a five part series on PyTorch foundations, and it assumes nothing. You do not need a machine learning background, a maths degree, or a graphics card. If you can write a Python function and run a script from a terminal, you are ready.
Every accuracy score, timing, and error message below came from actually running the code on PyTorch 2.13.0. None of it is illustrative, so if your numbers match, you know your setup is correct.
What a Neural Network Actually Is
Strip away the diagrams and a neural network is a very large arithmetic expression with adjustable numbers in it. You feed numbers in at one end, they get multiplied and added along the way, and numbers come out at the other end. The adjustable numbers are called weights, and learning means changing them until the outputs stop being wrong.
That is genuinely the whole idea. What makes it powerful is scale and repetition. The network you build below holds 109,386 adjustable numbers, and it will change every one of them roughly 2,800 times in fifteen seconds.
The short version
A neural network is a large arithmetic expression containing adjustable numbers. Training is the process of automatically nudging those numbers until the answers come out right.
The word learning does a lot of work in that sentence, so it is worth being precise about what it means here. The network is never told what a 7 looks like. Nobody writes a rule saying a 7 has a horizontal stroke on top. Instead the network guesses, gets told how wrong the guess was, and adjusts slightly in the direction that would have been less wrong. Repeat that a few thousand times and rules that nobody wrote emerge inside the weights.
Why Classifying Digits Is the Standard First Project
Handwritten digit recognition is the traditional starting point, and not out of nostalgia. It has four properties that make it useful for learning, and understanding those properties tells you what to look for when you pick your own first project later.
Guessing randomly across ten classes gets you about 10%. That number is your floor, and it is the reason the first thing you do below is measure the untrained model rather than start training immediately.
How Training Works, Without the Mathematics
Every training system, from this small network to the largest language models, runs the same four step cycle. Learning the cycle here means you already understand the shape of what happens everywhere else.
The model makes a guess. Data goes in, numbers come out. Early on those numbers are meaningless, because the weights started as noise.
A loss function scores the guess. It compares the guess against the correct answer and produces a single number. Large means badly wrong, near zero means nearly right.
Gradients say which direction helps. For every one of the 109,386 weights, the framework works out whether increasing or decreasing it would have made the loss smaller. You never do this calculation yourself.
An optimizer applies the change. It nudges each weight a small step in the helpful direction. How big that step is comes from the learning rate.
That cycle runs once per batch of images. With 60,000 images in batches of 64, one pass over the data runs it 938 times, and three passes run it 2,814 times.
Did you know
Notice that nothing in that cycle is specific to images or digits. Swap the input for server metrics and the ten digits for five incident categories, and every step stays identical. That transferability is why this tutorial is worth your time even if you never classify a digit again.
The Five Pieces Every PyTorch Project Contains
Before writing code, it helps to know the cast. Every PyTorch project you ever read will contain these five, usually in this order, and recognising them turns unfamiliar code into something you can skim.
The rest of this tutorial builds those five in order, then runs them.
What You Are Building
With the concepts in place, here is the concrete task. You are building a classifier that looks at a 28 by 28 pixel greyscale image of a handwritten digit and predicts which digit it is, from 0 to 9. The dataset is MNIST, which holds 60,000 training images and 10,000 test images collected from census workers and high school students.
What you need before you start
- Python 3.10 or newer, since current PyTorch releases require it.
- About 20 minutes, of which roughly 15 seconds is actual model training.
- Comfort running commands in a terminal. If Python itself is new to you, the beginner guide to starting with Python covers the syntax used here.
- No GPU, no cloud account, and no maths background beyond knowing what an average is.
Install PyTorch and Confirm It Works
Install the CPU build. It is a much smaller download than the CUDA build and everything in this tutorial runs on it. Upgrade pip first, because older versions fail partway through this particular install for a reason worth understanding.
In a terminal:
pip install --upgrade pip
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpuIn a Jupyter notebook cell, put a percent sign in front of each line:
%pip install --upgrade pip
%pip install torch torchvision --index-url https://download.pytorch.org/whl/cpuThe percent sign is not decoration. A notebook cell is Python, so pasting a shell command straight in gives you SyntaxError: invalid syntax. A single bare line sometimes survives because the kernel guesses your intent, but two lines never do. The %pip form is also the one that installs into the kernel you are actually running, which matters when a machine has several Python versions. Restart the kernel once the install finishes, then import torch.
Confirm the install before writing any model code. From a terminal:
python -c "import torch, torchvision; print(torch.__version__, torchvision.__version__)"Or in a notebook cell, where it is ordinary Python and needs no prefix:
import torch, torchvision
print(torch.__version__, torchvision.__version__)2.13.0+cpu 0.28.0+cpuIf you get a version string, you are ready. Those are the exact versions every output below was produced on.
Run it in your browser
You do not have to install anything on your own machine. The JupyterLab playground gives you a browser based notebook environment running on a Linux host, so you can open a terminal inside it, run the install command above, and work through every step of this tutorial in notebook cells. Sessions last one hour and extend by 15 minutes, which is comfortably longer than this tutorial needs. In notebook cells put a percent sign in front of each install line, as %pip install ..., because a cell runs Python rather than shell. Run the pip upgrade line first there as well, since the playground image ships an older pip that fails on this install.
Step 1: Load the Dataset
torchvision ships dataset loaders that handle downloading and parsing for you. The ToTensor() transform does two jobs: it converts the image into a PyTorch tensor and it rescales pixel values from the 0 to 255 range into 0.0 to 1.0.
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
torch.manual_seed(42)
transform = transforms.ToTensor()
train_data = datasets.MNIST(root="./data", train=True, download=True, transform=transform)
test_data = datasets.MNIST(root="./data", train=False, download=True, transform=transform)
print("Training images:", len(train_data))
print("Test images :", len(test_data))
img, label = train_data[0]
print("Image shape :", img.shape)
print("Pixel range :", float(img.min()), "to", float(img.max()))
print("First label :", label)Training images: 60000
Test images : 10000
Image shape : torch.Size([1, 28, 28])
Pixel range : 0.0 to 1.0
First label : 5
What this code does
torch.manual_seed(42)
Fixes the random number generator so your results match the ones printed here. Without it you get numbers that are close but never identical, which makes following along confusing.
transforms.ToTensor()
Does two jobs at once. It converts the image into a tensor, and it rescales pixel values from the usual 0 to 255 range down to 0.0 to 1.0. That rescaling is why the printed range reads 0.0 to 1.0.
train=True and train=False
Picks which half of the dataset you get. The training half is what the model learns from. The test half is held back so you can check whether it learned something general or simply memorised.
train_data[0]
Asks the dataset for its first item and gets back two things, the image and its correct answer. Here that answer is 5, so the first image is a handwritten five.
Did you know
Read torch.Size([1, 28, 28]) as one colour channel, 28 pixels high, 28 pixels wide. MNIST is greyscale, so there is one channel rather than the three you would see in a colour image. Getting fluent at reading shapes out loud is the single highest return habit in PyTorch.
Step 2: Batch the Data
You do not feed 60,000 images through the network at once. You feed small groups called batches, which keeps memory use low and makes the weight updates less erratic. DataLoader handles the grouping and the shuffling.
train_loader = DataLoader(train_data, batch_size=64, shuffle=True)
test_loader = DataLoader(test_data, batch_size=1000)
batch_imgs, batch_labels = next(iter(train_loader))
print("Batch images shape:", batch_imgs.shape)
print("Batch labels shape:", batch_labels.shape)Batch images shape: torch.Size([64, 1, 28, 28])
Batch labels shape: torch.Size([64])
What this code does
batch_size=64
Groups images into bundles of 64. The model looks at all 64, works out how wrong it was on average, then adjusts once. Adjusting after every single image would be noisy and slow.
shuffle=True on training
Reorders the images before each pass. Without it the model sees the same order every time and can start learning the order rather than the pictures.
No shuffle on the test loader
You are only scoring here, not learning, and order does not change a score. There is simply no reason to shuffle.
next(iter(train_loader))
A way to peek at exactly one batch without running a full loop, which is useful for checking shapes before you commit to training.
Batch size now sits at the front of the shape. That leading dimension is present on almost every tensor that moves through a PyTorch model, and forgetting it causes a large share of beginner errors.
Shuffling is on for training and off for testing. During training, shuffling stops the network from learning the order of the file rather than the content of the images. During testing, order does not affect the score, so there is no reason to shuffle.
Step 3: Define the Network
A PyTorch model is a class that inherits from nn.Module. You declare the layers in __init__ and describe how data flows through them in forward.
class DigitNet(nn.Module):
def __init__(self):
super().__init__()
self.flatten = nn.Flatten()
self.stack = nn.Sequential(
nn.Linear(28*28, 128),
nn.ReLU(),
nn.Linear(128, 64),
nn.ReLU(),
nn.Linear(64, 10),
)
def forward(self, x):
return self.stack(self.flatten(x))
model = DigitNet()
print(model)
print("Trainable parameters:", f"{sum(p.numel() for p in model.parameters()):,}")DigitNet(
(flatten): Flatten(start_dim=1, end_dim=-1)
(stack): Sequential(
(0): Linear(in_features=784, out_features=128, bias=True)
(1): ReLU()
(2): Linear(in_features=128, out_features=64, bias=True)
(3): ReLU()
(4): Linear(in_features=64, out_features=10, bias=True)
)
)
Trainable parameters: 109,386
What this code does
class DigitNet(nn.Module)
Every PyTorch model is a class that inherits from nn.Module. Doing so gets you weight tracking, saving, loading, and device movement for free.
__init__ versus forward
You declare which layers exist in __init__, and you describe the order data flows through them in forward. Think of __init__ as buying the parts and forward as assembling them.
nn.Flatten()
Squashes each 28 by 28 grid into one long row of 784 numbers, because a linear layer reads rows, not grids. The start_dim=1 in the output confirms it left the batch dimension alone.
nn.Linear(784, 128)
Takes 784 numbers in and produces 128 out, by multiplying against a grid of weights and adding a bias. Those weights are the part that actually gets learned.
nn.ReLU()
Replaces every negative number with zero and leaves positives untouched. It sounds trivial, and it is the reason the network can learn curved boundaries rather than only straight ones.
nn.Linear(64, 10)
The last layer produces ten numbers, one score per digit from 0 to 9. The highest score is the model's answer.
Two things in that output are worth pausing on. The ten numbers the last layer produces are raw scores called logits, which are not probabilities yet and deliberately so, for reasons the next step explains. And the parameter count of 109,386 is the first number in this tutorial that deserves a proper explanation.
Step 4: Choose a Loss Function and an Optimizer
The loss function scores how wrong a prediction is. The optimizer decides how to change the weights in response.
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
What this code does
nn.CrossEntropyLoss()
Turns a prediction and a correct answer into one number describing how badly the model missed. Bigger means worse. This is the standard choice when exactly one answer out of several is correct.
Why the last layer has no softmax
CrossEntropyLoss applies log softmax internally, which is why your final layer returns raw scores. The documentation describes it as LogSoftmax followed by NLLLoss in a single step. Adding a softmax yourself is a common and quietly damaging mistake, because the normalisation then happens twice.
torch.optim.Adam
The optimizer. It reads how wrong the model was and adjusts all 109,386 parameters in the direction that reduces the error. Adam tunes the step size per parameter as it goes.
lr=1e-3
The learning rate, meaning how big a step to take. Too large and the model overshoots and never settles. Too small and it crawls. This is the usual sensible default for Adam.
Step 5: Measure Accuracy Before You Train
Establish a baseline. This takes ten seconds and it makes the training result meaningful rather than merely impressive.
def evaluate():
model.eval()
correct = 0
with torch.no_grad():
for X, y in test_loader:
correct += (model(X).argmax(1) == y).sum().item()
return 100 * correct / len(test_data)
print(f"{evaluate():.2f}%")9.55%
What this code does
model.eval()
Switches the model into scoring mode. Some layer types behave differently while learning than while being tested, and this flips them over.
with torch.no_grad()
Tells PyTorch it does not need to track how to improve right now, because you are only measuring. This makes evaluation faster and uses less memory.
model(X).argmax(1)
Runs the batch through the model, then picks the position of the highest of the ten scores. That position is the predicted digit.
== y then .sum().item()
Compares predictions against correct answers to get a list of true and false values, counts the trues, then converts that count into a plain Python number.
With ten digit classes, random guessing gives about 10%. The untrained network sits right there, which confirms it has learned nothing yet.
Two details in that function matter beyond this tutorial. model.eval() switches layers like dropout and batch normalisation into inference behaviour. torch.no_grad() tells PyTorch to skip building the gradient graph, which makes evaluation faster and uses less memory. Neither is optional in production code.
Step 6: Write the Training Loop
This is the centre of the tutorial. Read the five lines inside the inner loop carefully, because you will write them again in every PyTorch project you ever build.
import time
for epoch in range(1, 4):
model.train()
t0 = time.time()
running = 0.0
for X, y in train_loader:
pred = model(X) # 1. forward pass
loss = loss_fn(pred, y) # 2. how wrong were we
optimizer.zero_grad() # 3. clear last step's gradients
loss.backward() # 4. compute new gradients
optimizer.step() # 5. update the weights
running += loss.item()
print(f"epoch {epoch} | avg loss {running/len(train_loader):.4f} | "
f"test accuracy {evaluate():.2f}% | {time.time()-t0:.1f}s")epoch 1 | avg loss 0.3334 | test accuracy 95.37% | 5.2s
epoch 2 | avg loss 0.1371 | test accuracy 96.49% | 5.3s
epoch 3 | avg loss 0.0939 | test accuracy 96.77% | 5.1s
What this code does
pred = model(X)
The forward pass. A batch of 64 images goes in and 64 sets of ten scores come out. At this point the model is guessing from whatever its parameters currently hold.
loss = loss_fn(pred, y)
Compares those guesses against the correct digits and boils the whole comparison down to one number.
optimizer.zero_grad()
Wipes the adjustment notes left over from the previous batch. PyTorch adds new notes to old ones rather than replacing them, so skipping this quietly corrupts training.
loss.backward()
Works backwards through the network figuring out, for each of the 109,386 parameters, whether nudging it up or down would reduce the loss. It stores that answer beside each parameter.
optimizer.step()
Reads those stored answers and applies the nudges. This is the only line that actually changes the model.
loss.item()
Pulls the loss out as a plain number for your running total. Using loss directly here would keep a lot of memory alive for no reason.
From 9.55% to 95.37% in a single pass over the data, in about five seconds.
The ordering of steps three and four is the part people get wrong. You clear the gradients before computing new ones, because PyTorch accumulates gradients by default rather than replacing them. The next section makes that behaviour visible.
Notice also that loss keeps falling while accuracy gains shrink. Loss measures confidence across every example, accuracy only counts whether the top guess was right. A model can become substantially more confident while converting very few additional wrong answers into right ones.
Step 7: Make a Single Prediction
Accuracy across 10,000 images is a summary. Looking at one prediction shows you what the model actually emits.
model.eval()
sample_img, sample_label = test_data[0]
with torch.no_grad():
logits = model(sample_img.unsqueeze(0))
probs = torch.softmax(logits, dim=1)
print("Logits :", [round(v, 2) for v in logits.squeeze().tolist()])
print("Predicted digit:", logits.argmax(1).item())
print("Confidence :", f"{probs.max().item()*100:.2f}%")
print("Actual digit :", sample_label)Logits : [-3.06, -4.34, 0.4, 1.15, -7.02, -3.58, -13.15, 8.68, -3.0, -0.23]
Predicted digit: 7
Confidence : 99.91%
Actual digit : 7
What this code does
sample_img.unsqueeze(0)
Adds the batch dimension back. The model always expects a batch, so a single image has to be presented as a batch containing one item.
logits
The ten raw scores. They are not probabilities and they do not add to 100. They are the model's unprocessed opinion, and the largest one wins.
torch.softmax(logits, dim=1)
Converts those raw scores into probabilities that add up to 1, which is what makes the confidence figure readable.
.argmax(1)
Returns the position of the largest score. Position 7 holds 8.68, far above everything else, so the model answers 7.
The eighth value, 8.68, is far above every other score, and it corresponds to the digit 7. Running softmax converts those raw scores into probabilities that sum to 1, giving 99.91% confidence. The model's second choice was the digit 3 at 0.05%, which is a reasonable confusion since 7 and 3 share an upper stroke.
unsqueeze(0) adds the batch dimension. The model expects a batch, so a single image has to be presented as a batch of one.
Step 8: Save and Load the Model
Training results are worthless if they vanish when the process exits.
torch.save(model.state_dict(), "digitnet.pth")
fresh = DigitNet()
fresh.load_state_dict(torch.load("digitnet.pth"))
fresh.eval()Saved model size: 430.3 KB
What this code does
model.state_dict()
Returns a plain dictionary holding every learned number in the model. It contains the values, not the code that produced them.
Why not save the model object
Saving the whole object bundles your class definition into the file, which breaks the moment you rename or move that class. Current PyTorch also refuses to load such a file by default, because unpickling arbitrary objects is a security risk. A state_dict loads reliably across versions and machines.
fresh = DigitNet()
Because the file holds only values, you first rebuild an empty model of the same shape, then pour the saved numbers into it.
fresh.eval()
A freshly loaded model starts in training mode. Switch it to evaluation mode before predicting or the results will be inconsistent.
Break It On Purpose
Reading about gradient accumulation is forgettable. Watching it happen is not.
Break it on purpose
Delete the optimizer.zero_grad() line from your training loop and run it again. Accuracy after epoch one drops noticeably and the loss curve becomes unstable.
Why it happens: PyTorch adds each new gradient onto whatever is already stored on the parameter. Here is the effect isolated, running the same backward pass three times without clearing:
grad magnitude without zero_grad(): [1.2995, 2.5989, 3.8984]
The gradient is not recomputed, it is summed. By step three it is triple the correct value, so the optimizer takes an oversized step in a direction that mixes three different batches together. Put the line back and the numbers return to the ones printed above.
This behaviour is not a design flaw. It is what makes gradient accumulation across several small batches possible when a large batch will not fit in memory, a technique used routinely when training large models. PyTorch simply requires you to opt out of it explicitly.
Error Decoder
These are the errors you are most likely to hit, with the exact text PyTorch prints. Each message below was produced by deliberately writing the broken code.
Where to Go From Here
You now have the full loop: load data, batch it, define a model, choose a loss and optimizer, train, evaluate, predict, save. That skeleton is the same whether the model has 109,386 parameters or 70 billion.
Three directions are worth taking next, in roughly this order.
Replace the linear layers with convolutions. A small convolutional network typically pushes MNIST accuracy past 99% because convolutions understand that nearby pixels are related, which a flattened vector cannot express.
Train on your own images rather than a bundled dataset. This is where most people stall, because it requires writing a custom Dataset class. That is the subject of part four in this series.
Understand the machinery under loss.backward(). You have been calling it without knowing what it does. Part three takes that apart.
Conclusion
The gap between not understanding neural networks and having trained one is a single afternoon, and most of that afternoon is installation. What you built is genuinely a neural network: it has weights, it learned them from data by gradient descent, and it generalises to images it has never seen at 96.77% accuracy.
Only a handful of ideas are worth carrying forward. Shapes matter more than maths, and reading them out loud catches most bugs before you run anything. The five step training loop is universal. Gradients accumulate unless you clear them. Evaluation needs model.eval() and torch.no_grad(). Everything else is variation on those ideas.
Keep the script open and change one thing at a time. Widen the hidden layer to 256 and see whether accuracy moves. Drop the learning rate to 1e-4 and watch training slow down. Remove a ReLU and see how much the network loses. Each of those takes fifteen seconds to test, and that fast feedback loop is the real reason to start on CPU rather than waiting for GPU access.
FAQS
Q1: Do I need to know calculus to build a neural network in PyTorch?
No. PyTorch computes every derivative for you through its autograd engine, so you never differentiate anything by hand. What you do need is comfort with Python functions and classes, plus a willingness to read tensor shapes carefully, because shape mismatches cause far more beginner errors than any mathematical misunderstanding. Knowing what a derivative represents conceptually, a measure of how much one value changes when another changes, is enough intuition to reason about why training works. If you want the deeper mathematical grounding later it will make you better at diagnosing unusual failures, but it is not a prerequisite for building working models. Plenty of engineers ship production models with exactly the level of maths described here.
Q2: Do I need a GPU to follow this tutorial?
No, and you should not start with one. Every result in this article was produced on the CPU only build of PyTorch, and the full three epoch training run finished in about fifteen seconds. A GPU becomes worthwhile when your model has millions of parameters or your dataset has hundreds of thousands of images, and neither applies here. Starting on CPU also avoids the most frustrating part of the ecosystem, which is matching CUDA driver versions to PyTorch builds. When you do need GPU acceleration later, the code change is small: move the model and each batch to the device with .to(device). Everything else in your training loop stays identical.
Q3: What is the difference between a Dataset and a DataLoader?
A Dataset knows how to fetch one sample and how many samples exist in total, so it answers the questions "give me item 500" and "how many items are there". A DataLoader wraps a Dataset and handles everything about how those samples are delivered: grouping them into batches, shuffling the order between passes, and stacking individual tensors into a single batched tensor. In this tutorial the Dataset returned one 1 by 28 by 28 image, while the DataLoader returned batches shaped 64 by 1 by 28 by 28. The separation matters because you write a custom Dataset for every new data source you use, but you almost never write a custom DataLoader. Building your own Dataset for a folder of images is covered in part four of this series.
Q4: Why does my model produce different results than the ones printed here?
Almost certainly because the random seed differs. Neural networks start with randomly initialised weights, and DataLoader shuffles the training data differently on each run, so two runs of identical code produce slightly different numbers. Setting torch.manual_seed(42) before creating the model fixes both sources of randomness and reproduces the figures in this article. Small variation is normal and expected even between seeds, so accuracy landing anywhere in the 96% to 97% range after three epochs means your code is working correctly. If you see accuracy stuck near 10% then something is genuinely broken, and the usual causes are a missing nn.Flatten() or a missing optimizer.zero_grad().
Q5: How is PyTorch different from TensorFlow, and which should I learn first?
Both frameworks do the same job and the concepts transfer between them almost completely. PyTorch builds its computation graph dynamically as your code runs, which means you can inspect tensors mid forward pass with a normal print statement and step through models with a standard Python debugger. That directness is why PyTorch dominates research and why most new tutorials, papers, and model releases target it first. TensorFlow retains a strong position in some established production stacks, particularly where TensorFlow Serving or TensorFlow Lite are already deployed. For a beginner in 2026, PyTorch is the pragmatic starting point, and the PyTorch course covers the full path from tensors through to model deployment.
Q6: I can build a model now, but how do I actually get it into production?
Training a model is roughly a third of the work. Production requires versioning your data and experiments, packaging the model with its exact dependencies, serving it behind an API, monitoring it for accuracy drift as real world data shifts away from your training set, and retraining on a schedule. That discipline is called MLOps, and it borrows heavily from practices DevOps engineers already know. If the term is new, the beginner's guide to MLOps explains the lifecycle end to end, and the Fundamentals of MLOps course covers experiment tracking with MLflow and model serving with BentoML. Engineers who can both train a model and operate it reliably are considerably rarer than those who can only do one.
Discussion