Skip to content

PyTorch

Chapter 33 18 code blocks 4163 words

The code in this chapter uses these filesrun it on your own machine

The scene: why a second library

In Chapter 32 we worked with Keras: five lines of code, everything automatic.

In PyTorch you write the training loop yourself. More code, more chances to make mistakes.

So why does it dominate research and modern models?

This chapter answers that question, starting with the reproducibility problem from the end of Chapter 32.

Column names

The rental data has Uzbek column names. xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built. The target ijaraga_berildi_30k means rented within 30 days.

The first difference: reproducibility

python10 lines
import os
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
import numpy as np
import pandas as pd
import torch
import torch.nn as nn

torch.set_num_threads(2)
print(f"PyTorch {torch.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}  (this chapter uses the CPU)")
Output
PyTorch 2.9.1+cu128
CUDA available: False  (this chapter uses the CPU)
python32 lines
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"]
X = SimpleImputer(strategy="median").fit_transform(df[num])
y = df["ijaraga_berildi_30k"].to_numpy(dtype="float32")
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)
sc = StandardScaler().fit(X_tr)
Xt = torch.tensor(sc.transform(X_tr), dtype=torch.float32)
Yt = torch.tensor(y_tr).unsqueeze(1)
Xv = torch.tensor(sc.transform(X_te), dtype=torch.float32)
Yv = torch.tensor(y_te).unsqueeze(1)

def train_once(seed=42, epochs=150):
    torch.manual_seed(seed)
    model = nn.Sequential(nn.Linear(Xt.shape[1], 16), nn.ReLU(), nn.Linear(16, 1))
    opt = torch.optim.Adam(model.parameters(), lr=0.01)
    criterion = nn.BCEWithLogitsLoss()
    for _ in range(epochs):
        opt.zero_grad()
        loss = criterion(model(Xt), Yt)
        loss.backward()
        opt.step()
    with torch.no_grad():
        return float(criterion(model(Xv), Yv))

results = [round(train_once(), 6) for _ in range(3)]
print("The same seed, trained three times:")
print(f"  {results}")
print(f"  All the same: {len(set(results)) == 1}")
Output
The same seed, trained three times:
  [0.496166, 0.496166, 0.496166]
  All the same: True

In Chapter 32 we ran the same test with TensorFlow, and it gave a different result every time. PyTorch reproduces exactly with torch.manual_seed().

Why reproducibility matters

  • When you're hunting a bug: if the result changes, you can't tell whether your fix helped or chance did
  • When a colleague checks your result: if they get another number, nobody knows who's right
  • In this book: every number must come out the same when the book is rebuilt

That's why I write this chapter's numbers into the text too, which I couldn't do in Chapter 32.

A tensor: a numpy array with two additions

python14 lines
a = torch.tensor([[1., 2., 3.], [4., 5., 6.]])
print(f"shape : {a.shape}")
print(f"dtype : {a.dtype}")
print(f"device: {a.device}")
print()
print("Almost the same as numpy:")
print(f"  a @ a.T     ->\n{a @ a.T}")
print(f"  a.mean(0)   -> {a.mean(0)}")
print()
print("To numpy and back: THE MEMORY IS SHARED:")
n = a.numpy()
n[0, 0] = 999
print(f"  we changed the numpy array -> torch tensor: {a[0, 0]}")
print("  (if you need a copy: a.numpy().copy() or torch.tensor(n))")
Output
shape : torch.Size([2, 3])
dtype : torch.float32
device: cpu

Almost the same as numpy:
  a @ a.T     ->
tensor([[14., 32.],
        [32., 77.]])
  a.mean(0)   -> tensor([2.5000, 3.5000, 4.5000])

To numpy and back: THE MEMORY IS SHARED:
  we changed the numpy array -> torch tensor: 999.0
  (if you need a copy: a.numpy().copy() or torch.tensor(n))

A tensor has two differences from numpy:

python6 lines
show(pd.DataFrame([
    {"Addition": "1. requires_grad", "What it gives you": "Automatic differentiation (autograd)",
     "In numpy": "none: you write the gradient by hand (Chapter 13)"},
    {"Addition": "2. device", "What it gives you": "Moving to the GPU: .to('cuda')",
     "In numpy": "none: CPU only"},
]))
AdditionWhat it gives youIn numpy
1. requires_gradAutomatic differentiation (autograd)none: you write the gradient by hand (Chapter 13)
2. deviceMoving to the GPU: .to('cuda')none: CPU only

Autograd: the end of the hand work from Chapter 13

In Chapter 13 we computed the gradient by hand. In Chapter 31 we wrote out the chain rule. PyTorch does it automatically.

python17 lines
# repeat the example from Chapter 31: L = (w2*(w1*x)^2 - y)^2
x_, y_true = 2.0, 10.0
w1 = torch.tensor(3.0, requires_grad=True)
w2 = torch.tensor(0.5, requires_grad=True)

z1 = w1 * x_
a1 = z1 ** 2
z2 = w2 * a1
L = (z2 - y_true) ** 2
L.backward()                      # ONE call: the whole chain is computed

print(f"L = {float(L):.1f}")
print(f"  dL/dw1 = {float(w1.grad):.1f}")
print(f"  dL/dw2 = {float(w2.grad):.1f}")
print()
print("The values computed by hand in Chapter 31: dL/dw1 = 192.0, dL/dw2 = 576.0")
print(f"Do they match: {bool(abs(float(w1.grad) - 192.0) < 1e-4 and abs(float(w2.grad) - 576.0) < 1e-4)}")
Output
L = 64.0
  dL/dw1 = 192.0
  dL/dw2 = 576.0

The values computed by hand in Chapter 31: dL/dw1 = 192.0, dL/dw2 = 576.0
Do they match: True

L.backward() is one line. In Chapter 31 that took seven.

python5 lines
print("The computation graph was built automatically:")
print(f"  L.grad_fn           : {L.grad_fn}")
print(f"  one step back       : {L.grad_fn.next_functions[0][0]}")
print()
print("Every operation is added to the graph, and backward() walks it in reverse, collecting gradients.")
Output
The computation graph was built automatically:
  L.grad_fn           : <PowBackward0 object at 0x7fa436387550>
  one step back       : <SubBackward0 object at 0x7fa436385e40>

Every operation is added to the graph, and backward() walks it in reverse, collecting gradients.

Gradients ACCUMULATE: don't forget zero_grad()

PyTorch adds to the gradient; it doesn't replace it. That's useful for RNNs and some techniques, but in an ordinary loop it's a source of bugs.

python15 lines
w = torch.tensor(1.0, requires_grad=True)
print("If zero_grad() is NOT called:")
for i in range(3):
    loss = (w * 2) ** 2
    loss.backward()
    print(f"  call {i+1} -> w.grad = {float(w.grad):.1f}   (it ADDS UP each time)")
print()
w2_ = torch.tensor(1.0, requires_grad=True)
print("Clearing at every step:")
for i in range(3):
    if w2_.grad is not None:
        w2_.grad.zero_()
    loss = (w2_ * 2) ** 2
    loss.backward()
    print(f"  call {i+1} -> w.grad = {float(w2_.grad):.1f}   (correct)")
Output
If zero_grad() is NOT called:
  call 1 -> w.grad = 8.0   (it ADDS UP each time)
  call 2 -> w.grad = 16.0   (it ADDS UP each time)
  call 3 -> w.grad = 24.0   (it ADDS UP each time)

Clearing at every step:
  call 1 -> w.grad = 8.0   (correct)
  call 2 -> w.grad = 8.0   (correct)
  call 3 -> w.grad = 8.0   (correct)

The training loop: five lines

In Keras, model.fit() is one line. In PyTorch you write the loop yourself, and these five lines are always the same:

python28 lines
torch.manual_seed(42)
model = nn.Sequential(
    nn.Linear(Xt.shape[1], 32), nn.ReLU(),
    nn.Linear(32, 16), nn.ReLU(),
    nn.Linear(16, 1),
)
opt = torch.optim.Adam(model.parameters(), lr=0.01)
criterion = nn.BCEWithLogitsLoss()

history = {"train": [], "val": []}
for epoch in range(200):
    model.train()
    opt.zero_grad()                       # 1. clear the gradient
    output = model(Xt)                    # 2. forward
    loss = criterion(output, Yt)          # 3. compute the loss
    loss.backward()                       # 4. backward
    opt.step()                            # 5. update the weights

    model.eval()
    with torch.no_grad():
        history["train"].append(float(loss))
        history["val"].append(float(criterion(model(Xv), Yv)))

with torch.no_grad():
    acc = float(((torch.sigmoid(model(Xv)) > 0.5).float() == Yv).float().mean())
print(f"Final train loss : {history['train'][-1]:.4f}")
print(f"Final val loss   : {history['val'][-1]:.4f}")
print(f"Test accuracy    : {acc:.4f}")
Output
Final train loss : 0.2539
Final val loss   : 0.9188
Test accuracy    : 0.6611
python7 lines
show(pd.DataFrame([
    {"Step": "opt.zero_grad()", "Why": "So gradients don't accumulate"},
    {"Step": "output = model(X)", "Why": "The forward pass; the graph is built"},
    {"Step": "loss = criterion(...)", "Why": "Computing the loss"},
    {"Step": "loss.backward()", "Why": "Computing the gradients (autograd)"},
    {"Step": "opt.step()", "Why": "Updating the weights (the Chapter 13 rule)"},
]))
StepWhy
opt.zero_grad()So gradients don't accumulate
output = model(X)The forward pass; the graph is built
loss = criterion(...)Computing the loss
loss.backward()Computing the gradients (autograd)
opt.step()Updating the weights (the Chapter 13 rule)

BCEWithLogitsLoss vs BCELoss: you must know the difference

The last layer of the network above is nn.Linear(16, 1), with no sigmoid.

The reason: BCEWithLogitsLoss includes the sigmoid, and does it in a numerically stable way.

  • nn.Linear(...) + BCEWithLogitsLoss() -> CORRECT
  • nn.Sigmoid() + BCELoss() -> works, but less stable
  • nn.Sigmoid() + BCEWithLogitsLoss() -> WRONG: the sigmoid is applied twice

The last variant raises no error: the model simply learns badly. We measure it below.

python18 lines
def sigmoid_test(twice):
    torch.manual_seed(42)
    layers = [nn.Linear(Xt.shape[1], 16), nn.ReLU(), nn.Linear(16, 1)]
    if twice:
        layers.append(nn.Sigmoid())          # then BCEWithLogits applies sigmoid again
    m = nn.Sequential(*layers)
    opt_ = torch.optim.Adam(m.parameters(), lr=0.01)
    k = nn.BCEWithLogitsLoss()
    for _ in range(200):
        opt_.zero_grad(); k(m(Xt), Yt).backward(); opt_.step()
    m.eval()
    with torch.no_grad():
        out = m(Xv)
        pred = (torch.sigmoid(out) > 0.5).float() if not twice else (out > 0.5).float()
        return float(((pred == Yv).float().mean()))

print(f"Right (Linear + BCEWithLogits)   : {sigmoid_test(False):.4f}")
print(f"WRONG (Sigmoid + BCEWithLogits)  : {sigmoid_test(True):.4f}")
Output
Right (Linear + BCEWithLogits)   : 0.7750
WRONG (Sigmoid + BCEWithLogits)  : 0.7056

Dataset and DataLoader

So far we fed all the data at once (full-batch). In practice you need mini-batches (Chapter 13).

python15 lines
from torch.utils.data import TensorDataset, DataLoader

ds_tr = TensorDataset(Xt, Yt)
ds_te = TensorDataset(Xv, Yv)
dl_tr = DataLoader(ds_tr, batch_size=64, shuffle=True,
                   generator=torch.Generator().manual_seed(42))
dl_te = DataLoader(ds_te, batch_size=64)

print(f"Train samples    : {len(ds_tr)}")
print(f"Batch size       : 64")
print(f"Number of batches: {len(dl_tr)}")
print()
for xb, yb in dl_tr:
    print(f"One batch's shape: X={tuple(xb.shape)}  y={tuple(yb.shape)}")
    break
Output
Train samples    : 840
Batch size       : 64
Number of batches: 14

One batch's shape: X=(64, 7)  y=(64, 1)
python25 lines
def train_dataloader(epochs=60):
    torch.manual_seed(42)
    m = nn.Sequential(nn.Linear(Xt.shape[1], 32), nn.ReLU(),
                      nn.Dropout(0.2), nn.Linear(32, 1))
    opt_ = torch.optim.Adam(m.parameters(), lr=0.01)
    k = nn.BCEWithLogitsLoss()
    hist = []
    for _ in range(epochs):
        m.train()
        loss_total = 0.0
        for xb, yb in dl_tr:
            opt_.zero_grad()
            loss_ = k(m(xb), yb)
            loss_.backward()
            opt_.step()
            loss_total += float(loss_) * len(xb)
        hist.append(loss_total / len(ds_tr))
    m.eval()
    with torch.no_grad():
        correct = sum(float(((torch.sigmoid(m(xb)) > .5).float() == yb).float().sum()) for xb, yb in dl_te)
    return m, hist, correct / len(ds_te)

m_dl, hist_dl, acc_dl = train_dataloader()
print(f"Test accuracy with mini-batches : {acc_dl:.4f}")
print(f"With full-batch (above)         : {acc:.4f}")
Output
Test accuracy with mini-batches : 0.7417
With full-batch (above)         : 0.6611

Freedom: writing your own layer

This is PyTorch's main advantage: you can change anything.

python36 lines
class MyLayer(nn.Module):
    """Linear + ReLU + a residual connection (the ResNet idea from Chapter 34)."""
    def __init__(self, n):
        super().__init__()
        self.linear = nn.Linear(n, n)
        self.norm = nn.LayerNorm(n)

    def forward(self, x):
        return x + torch.relu(self.norm(self.linear(x)))   # x + ... = residual


class MyNetwork(nn.Module):
    def __init__(self, n_in, hidden=32, n_blocks=3):
        super().__init__()
        self.inp = nn.Linear(n_in, hidden)
        self.blocks = nn.ModuleList([MyLayer(hidden) for _ in range(n_blocks)])
        self.out = nn.Linear(hidden, 1)

    def forward(self, x):
        x = torch.relu(self.inp(x))
        for b in self.blocks:
            x = b(x)
        return self.out(x)

torch.manual_seed(42)
mt = MyNetwork(Xt.shape[1])
print(f"Number of parameters: {sum(p.numel() for p in mt.parameters())}")
opt_mt = torch.optim.Adam(mt.parameters(), lr=0.01)
k_mt = nn.BCEWithLogitsLoss()
for _ in range(200):
    mt.train(); opt_mt.zero_grad(); k_mt(mt(Xt), Yt).backward(); opt_mt.step()
mt.eval()
with torch.no_grad():
    acc_mt = float(((torch.sigmoid(mt(Xv)) > .5).float() == Yv).float().mean())
print(f"Residual network test accuracy: {acc_mt:.4f}")
print(f"Plain Sequential (above)      : {acc:.4f}")
Output
Number of parameters: 3649
Residual network test accuracy: 0.6722
Plain Sequential (above)      : 0.6611

You can't write this network with a Keras Sequential: a residual connection needs the Functional API. In PyTorch you just write ordinary Python inside forward().

Saving a model: state_dict

python17 lines
path = ISH / "model.pt"
torch.save(model.state_dict(), path)
print(f"Saved: {path.name}  ({path.stat().st_size / 1024:.0f} KB)")
print()
# loading NEEDS the architecture
torch.manual_seed(0)      # a different seed, so the weights are different
fresh = nn.Sequential(nn.Linear(Xt.shape[1], 32), nn.ReLU(),
                      nn.Linear(32, 16), nn.ReLU(), nn.Linear(16, 1))
with torch.no_grad():
    before = float(nn.BCEWithLogitsLoss()(fresh(Xv), Yv))
fresh.load_state_dict(torch.load(path))
fresh.eval()
with torch.no_grad():
    after = float(nn.BCEWithLogitsLoss()(fresh(Xv), Yv))
print(f"Val loss BEFORE loading     : {before:.4f}")
print(f"Val loss AFTER loading      : {after:.4f}")
print(f"Same as the original model  : {bool(abs(after - history['val'][-1]) < 1e-5)}")
Output
Saved: model.pt  (6 KB)

Val loss BEFORE loading     : 0.6822
Val loss AFTER loading      : 0.9188
Same as the original model  : True

state_dict does NOT save the architecture

Keras model.save() saves the network's structure too. PyTorch state_dict() saves only the weights.

So to load it you need exactly the same architecture code. Change a layer's size and load_state_dict() raises an error.

That matters in production: you have to keep the code version alongside the model file. The MLOps book covers this.

torch.save(model) saves the whole object, but it relies on pickle: the security problem from Chapter 40 (an untrusted file executes code).

python6 lines
print("When the architecture doesn't match:")
wrong = nn.Sequential(nn.Linear(Xt.shape[1], 64), nn.ReLU(), nn.Linear(64, 1))
try:
    wrong.load_state_dict(torch.load(path))
except RuntimeError as e:
    print(f"  RuntimeError: {str(e).splitlines()[0][:72]}")
Output
When the architecture doesn't match:
  RuntimeError: Error(s) in loading state_dict for Sequential:

Keras and PyTorch side by side

python8 lines
show(pd.DataFrame([
    {"": "Keras", "Training loop": "model.fit(): automatic",
     "Reproducible": "NO (Chapter 32)", "Flexibility": "Medium",
     "When": "Standard tasks, quick prototypes"},
    {"": "PyTorch", "Training loop": "By hand: 5 lines",
     "Reproducible": "YES (manual_seed)", "Flexibility": "Full",
     "When": "Research, non-standard architectures, LLMs"},
]))
Training loopReproducibleFlexibilityWhen
Kerasmodel.fit(): automaticNO (Chapter 32)MediumStandard tasks, quick prototypes
PyTorchBy hand: 5 linesYES (manual_seed)FullResearch, non-standard architectures, LLMs
python11 lines
import time
from sklearn.linear_model import LogisticRegression

print(f"{'model':<36} {'test accuracy':>14} {'time':>8}")
print("-" * 62)
t0 = time.perf_counter()
lr_m = LogisticRegression(max_iter=3000).fit(sc.transform(X_tr), y_tr)
print(f"{'LogisticRegression (Chapter 16)':<36} {lr_m.score(sc.transform(X_te), y_te):>14.4f} {time.perf_counter()-t0:>7.2f}s")
print(f"{'PyTorch Sequential (32-16)':<36} {acc:>14.4f} {'—':>8}")
print(f"{'PyTorch + DataLoader + Dropout':<36} {acc_dl:>14.4f} {'—':>8}")
print(f"{'PyTorch residual (my own layer)':<36} {acc_mt:>14.4f} {'—':>8}")
Output
model                                 test accuracy     time
--------------------------------------------------------------
LogisticRegression (Chapter 16)              0.7694    0.00s
PyTorch Sequential (32-16)                   0.6611        —
PyTorch + DataLoader + Dropout               0.7417        —
PyTorch residual (my own layer)              0.6722        —

The conclusion from Chapter 30 is confirmed again: on tabular data a complex network doesn't beat a simple model.

Which one to learn

Both, but in order.

Start with PyTorch if you want to understand: when you write the loop yourself, the meaning of zero_grad, backward and step becomes clear.

Use Keras for standard tasks: quick prototypes, simple architectures.

Modern practice: research and LLMs are almost entirely PyTorch. HuggingFace, LLaMA, Stable Diffusion: all PyTorch.

What we learned today

python10 lines
show(pd.DataFrame([
    {"Concept": "Reproducibility", "In one sentence": "PyTorch reproduces exactly with manual_seed; TF doesn't"},
    {"Concept": "Tensor", "In one sentence": "numpy + requires_grad + device"},
    {"Concept": "Autograd", "In one sentence": "backward() squeezes the hand work from Chapter 31 into one line"},
    {"Concept": "zero_grad()", "In one sentence": "Gradients ACCUMULATE: clearing them is mandatory"},
    {"Concept": "The training loop", "In one sentence": "zero_grad -> forward -> loss -> backward -> step"},
    {"Concept": "BCEWithLogitsLoss", "In one sentence": "The sigmoid is inside: DON'T add a sigmoid layer"},
    {"Concept": "nn.Module", "In one sentence": "Ordinary Python inside forward(): full freedom"},
    {"Concept": "state_dict", "In one sentence": "Weights only; you need the architecture code separately"},
]))
ConceptIn one sentence
ReproducibilityPyTorch reproduces exactly with manual_seed; TF doesn't
Tensornumpy + requires_grad + device
Autogradbackward() squeezes the hand work from Chapter 31 into one line
zero_grad()Gradients ACCUMULATE: clearing them is mandatory
The training loopzero_grad -> forward -> loss -> backward -> step
BCEWithLogitsLossThe sigmoid is inside: DON'T add a sigmoid layer
nn.ModuleOrdinary Python inside forward(): full freedom
state_dictWeights only; you need the architecture code separately

The next chapter moves to images, and that's where a neural network really does win.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents