The PyTorch article promise is that deep learning is a workflow that is the same every single time: tensors in, layers stacked, a loss measured, gradients computed, weights nudged, repeat. This workbook builds that workflow piece by piece, so that by Example 8 you are reading the full training loop as a familiar ritual rather than a wall of code. Run them in order; each one uses what came before.
1. Tensors: the foundation
A tensor is a NumPy array with two superpowers: it can live on a GPU, and it can remember the operations performed on it. Creation and the key attributes come first.
import torchreadings = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) # from a Python listzeros = torch.zeros(2, 3) # 2x3 of zerosnoise = torch.randn(2, 2) # random normal valuesprint(readings.shape) # torch.Size([2, 2])print(readings.dtype) # torch.float32print(readings.device) # cpu# operations look like NumPyprint(readings + noise) # elementwise addprint(readings @ readings) # matrix multiplyprint(readings.mean(), readings.sum())
.shape, .dtype, and .device are the three attributes you will check constantly when debugging, because most PyTorch errors are a shape mismatch, a type mismatch, or two tensors on different devices. The operations themselves read exactly like NumPy.
2. Layers and nn.Sequential
nn.Linear is a fully connected layer: a matrix of weights plus a bias vector. nn.Sequential pipes data through a stack of layers in order, which is enough to define a real network.
import torchimport torch.nn as nnmodel = nn.Sequential( nn.Linear(4, 8), # 4 inputs -> 8 hidden units nn.ReLU(), nn.Linear(8, 2) # 8 hidden -> 2 outputs)sample = torch.randn(1, 4) # one row with 4 featuresoutput = model(sample)print(output) # two raw scores# count the parameterstotal = sum(p.numel() for p in model.parameters())print(f"Parameters: {total}") # (4*8 + 8) + (8*2 + 2) = 58
The parameter count is worth doing by hand once: each Linear holds inputs × outputs weights plus one bias per output, so this little network has 58 learnable numbers. Training is nothing more than finding good values for them.
3. Activation functions
Without activations, stacked linear layers collapse into one big linear layer. Each activation has a job: sigmoid squashes to a probability, softmax makes scores sum to one, ReLU keeps hidden layers non-linear and fast.
import torchimport torch.nn as nnscores = torch.tensor([-2.0, 0.0, 3.0])print(nn.Sigmoid()(scores)) # [0.119, 0.500, 0.953] each in (0, 1)print(nn.Softmax(dim=-1)(scores)) # [0.006, 0.047, 0.946] sums to 1print(nn.ReLU()(scores)) # [0.0, 0.0, 3.0] negatives become zeroprint(nn.LeakyReLU(0.1)(scores)) # [-0.2, 0.0, 3.0] a trickle survives
Read the outputs against the inputs. Sigmoid turns any number into something probability-like, softmax turns a set of scores into a distribution, and ReLU simply cuts negatives to zero. Leaky ReLU lets a small negative trickle through, which prevents the “dying ReLU” problem where a neuron gets stuck outputting zero forever.
4. Loss functions
The loss is a single number measuring how wrong the model is. Regression uses mean squared error; multi-class classification uses cross-entropy. One critical convention: CrossEntropyLoss applies softmax internally, so your model must output raw scores.
import torchimport torch.nn as nn# regression: MSEprediction = torch.tensor([2.5, 0.0, 2.1])target = torch.tensor([3.0, -0.5, 2.0])mse = nn.MSELoss()print(mse(prediction, target)) # mean of squared differences# multi-class: CrossEntropy on RAW scores (logits)logits = torch.tensor([[2.0, 0.5, -1.0]]) # no softmax applied!label = torch.tensor([0]) # the true class indexce = nn.CrossEntropyLoss()print(ce(logits, label))
The comment is the lesson: do not add a softmax layer to a model trained with CrossEntropyLoss, because the loss applies it internally and doubling it up quietly hurts training. This is one of the most common beginner mistakes in PyTorch.
5. Autograd: where gradients come from
Set requires_grad=True and a tensor remembers every operation done to it. Calling .backward() on a result walks that history in reverse, filling each tensor’s .grad with its gradient.
import torchw = torch.tensor(2.0, requires_grad=True) # a "weight" we want to tunex = torch.tensor(3.0) # an inputy = w * x # forward: y = 6loss = (y - 12) ** 2 # we wanted 12, we got 6, loss = 36loss.backward() # compute d(loss)/d(w) automaticallyprint(loss.item()) # 36.0print(w.grad) # -36.0: increasing w decreases the loss
The gradient of -36 says “the loss falls if you increase w,” which points exactly toward the right answer (w = 4 would give y = 12). Every deep network does precisely this, just across millions of weights at once, and loss.backward() is the line that makes it happen.
6. The optimizer ritual
An optimizer uses those gradients to nudge the weights. The three-line ritual, zero the gradients, backward, step, is the heartbeat of all PyTorch training. Watch it reduce a loss in one step.
import torchimport torch.nn as nnmodel = nn.Linear(2, 1)optimizer = torch.optim.SGD(model.parameters(), lr=0.1)criterion = nn.MSELoss()features = torch.tensor([[1.0, 2.0]])target = torch.tensor([[5.0]])for step in range(3): optimizer.zero_grad() # 1. clear old gradients prediction = model(features) # 2. forward pass loss = criterion(prediction, target) loss.backward() # 3. compute gradients optimizer.step() # 4. nudge the weights print(f"step {step}: loss {loss.item():.4f}")# loss shrinks every step
zero_grad matters because PyTorch accumulates gradients by default; forget it and each step uses stale gradients from previous passes. SGD is the baseline optimizer; swap in torch.optim.Adam(model.parameters(), lr=0.01) and nothing else changes, which is why Adam is the robust default.
7. Datasets and DataLoaders
Real training runs on batches, not single rows. TensorDataset zips features and labels together, and DataLoader serves them up in shuffled batches.
import torchfrom torch.utils.data import TensorDataset, DataLoaderfeatures = torch.randn(100, 4) # 100 samples, 4 featureslabels = torch.randint(0, 2, (100,)) # 100 binary labelsdataset = TensorDataset(features, labels)loader = DataLoader(dataset, batch_size=32, shuffle=True)for batch_features, batch_labels in loader: print(batch_features.shape, batch_labels.shape)# torch.Size([32, 4]) torch.Size([32]) x3, then a final partial batch of 4
Shuffling matters for training, so the model never sees the data in a memorisable order, and is switched off for validation so results are reproducible. Batch sizes of 32, 64, or 128 are the common defaults.
8. The full training loop
Everything assembles here. Synthetic data with a real learnable pattern, a small network, Adam, and the ritual from Example 6 wrapped in epochs and batches. Watch the loss fall.
import torchimport torch.nn as nnfrom torch.utils.data import TensorDataset, DataLoader# synthetic two-class data: two clouds of pointstorch.manual_seed(0)class_a = torch.randn(100, 2) + torch.tensor([2.0, 2.0])class_b = torch.randn(100, 2) + torch.tensor([-2.0, -2.0])features = torch.cat([class_a, class_b])labels = torch.cat([torch.zeros(100), torch.ones(100)]).long()loader = DataLoader(TensorDataset(features, labels), batch_size=32, shuffle=True)model = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 2))criterion = nn.CrossEntropyLoss()optimizer = torch.optim.Adam(model.parameters(), lr=0.01)for epoch in range(5): epoch_loss = 0 for batch_features, batch_labels in loader: optimizer.zero_grad() prediction = model(batch_features) loss = criterion(prediction, batch_labels) loss.backward() optimizer.step() epoch_loss += loss.item() print(f"epoch {epoch}: loss {epoch_loss / len(loader):.4f}")# epoch 0: ~0.4 ... epoch 4: ~0.01
Say the ritual out loud as you read the inner loop: zero the gradients, forward pass, compute the loss, backward pass, take a step. Every PyTorch training loop you will ever read, at any scale, is this same structure with different ingredients.
9. Evaluation: eval mode and no_grad
Measuring a trained model needs two switches: model.eval() puts layers like dropout into inference behaviour, and torch.no_grad() stops gradient tracking, saving memory and time. Continue from Example 8’s trained model.
# (run immediately after Example 8, reusing model and data)model.eval() # inference behaviour for dropout etc.with torch.no_grad(): # no gradient bookkeeping needed logits = model(features) predicted = logits.argmax(dim=1) # highest-scoring class per row accuracy = (predicted == labels).float().mean()print(f"Accuracy: {accuracy:.2%}") # ~100% on this easy synthetic datamodel.train() # switch back before more training
The clouds are far apart, so near-perfect accuracy just proves the pipeline works. The habit to build is the pairing: eval()plus no_grad() for every evaluation, then train() before any further training. Forgetting these switches is a classic source of silently wrong results in models that use dropout or batch norm.
10. Saving, loading, and freezing
A trained model should outlive its script. state_dict saves the weights, load_state_dict restores them, and freezing parameters with requires_grad = False is the mechanism behind transfer learning.
import torchimport torch.nn as nnmodel = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 2))# save weights only (the recommended way)torch.save(model.state_dict(), "classifier.pt")# later, or in another script: rebuild the architecture, then loadrestored = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 2))restored.load_state_dict(torch.load("classifier.pt"))restored.eval()# transfer learning pattern: freeze early layers, retrain the headfor param in restored[0].parameters(): param.requires_grad = False # the first Linear stops learningtrainable = [p for p in restored.parameters() if p.requires_grad]print(f"Trainable tensors remaining: {len(trainable)}") # just the head
state_dict saves weights rather than the whole object, which is why you rebuild the architecture before loading, a deliberate design that keeps saved models portable. The freezing pattern is exactly how pretrained models are adapted: lock the early layers that learned general features, retrain the head on your task, and use a low learning rate such as 1e-5 for anything left unfrozen.
Work through these and you will have touched the whole article: tensors, layers, activations, losses, autograd, optimizers, data loading, the training loop, evaluation, and persistence. Two closing habits from the guide are worth pinning up. The learning rate is the hyperparameter that matters most, and searching it on a log scale beats a linear grid. And before any long training run, deliberately overfit a single batch: if your pipeline cannot drive the loss to near zero on 32 samples, something is wired wrong, and it is far cheaper to find out in thirty seconds than after an hour of training.
See you soon, Andrei.
[…] PyTorch Deep Learning Fundamentals: 10 Code-Along Examples […]
[…] PyTorch Deep Learning Fundamentals: 10 Code-Along Examples […]
[…] For the full background, read the guide to PyTorch deep learning fundamentals. To practise, work through the 10 code-along examples. […]