PyTorch
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
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)")
PyTorch 2.9.1+cu128 CUDA available: False (this chapter uses the CPU)
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}")
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
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))")
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:
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"}, ]))
| Addition | What it gives you | In numpy |
|---|---|---|
| 1. requires_grad | Automatic differentiation (autograd) | none: you write the gradient by hand (Chapter 13) |
| 2. device | Moving 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.
# 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)}")
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.
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.")
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.
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)")
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:
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}")
Final train loss : 0.2539 Final val loss : 0.9188 Test accuracy : 0.6611
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)"}, ]))
| Step | Why |
|---|---|
| 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()-> CORRECTnn.Sigmoid()+BCELoss()-> works, but less stablenn.Sigmoid()+BCEWithLogitsLoss()-> WRONG: the sigmoid is applied twice
The last variant raises no error: the model simply learns badly. We measure it below.
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}")
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).
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
Train samples : 840 Batch size : 64 Number of batches: 14 One batch's shape: X=(64, 7) y=(64, 1)
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}")
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.
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}")
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
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)}")
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).
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]}")
When the architecture doesn't match: RuntimeError: Error(s) in loading state_dict for Sequential:
Keras and PyTorch side by side
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 loop | Reproducible | Flexibility | When | |
|---|---|---|---|---|
| Keras | model.fit(): automatic | NO (Chapter 32) | Medium | Standard tasks, quick prototypes |
| PyTorch | By hand: 5 lines | YES (manual_seed) | Full | Research, non-standard architectures, LLMs |
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}")
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
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"}, ]))
| Concept | In one sentence |
|---|---|
| Reproducibility | PyTorch reproduces exactly with manual_seed; TF doesn't |
| Tensor | numpy + requires_grad + device |
| Autograd | backward() squeezes the hand work from Chapter 31 into one line |
| zero_grad() | Gradients ACCUMULATE: clearing them is mandatory |
| The training loop | zero_grad -> forward -> loss -> backward -> step |
| BCEWithLogitsLoss | The sigmoid is inside: DON'T add a sigmoid layer |
| nn.Module | Ordinary Python inside forward(): full freedom |
| state_dict | Weights 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.