Frontend Labs
⋯

58. PyTorch: tensors, nn.Module, and the training loop

58.1 Tensors and devices

import torch

device = (
    "cuda" if torch.cuda.is_available()          # NVIDIA GPU
    else "mps" if torch.backends.mps.is_available()   # Apple Silicon GPU
    else "cpu"
)
x = torch.randn(4, 3, device=device)             # like np.random.normal, but can live on a GPU
w = torch.randn(3, 2, device=device, requires_grad=True)   # track gradients for w
y = (x @ w).relu().sum()
y.backward()
print(device, y.item(), w.grad.shape)            # .item() copies a scalar back to Python
print(torch.from_numpy(x.cpu().numpy()).shape)   # NumPy <-> torch interop (CPU tensors share memory)

A tensor is an ndarray that can live on a GPU and that records the operations applied to it, exactly like Value in Ch 57. Moving data between CPU and GPU (.to(device), .cpu(), .item()) is slow relative to computing; keep data on the device for the whole loop, and avoid .item() inside the hot path.

58.2 A complete training loop

# labs/train_mlp.py: train a small classifier the standard PyTorch way
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset, random_split

torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"

# Data: points inside/outside a circle (the tinygrad task, now with 10 000 samples)
X = torch.rand(10_000, 2) * 2 - 1
y = ((X ** 2).sum(dim=1) < 0.5).long()               # class 0 or 1
train_ds, val_ds = random_split(TensorDataset(X, y), [8000, 2000])
train_dl = DataLoader(train_ds, batch_size=128, shuffle=True)
val_dl = DataLoader(val_ds, batch_size=512)

class MLP(nn.Module):
    def __init__(self, hidden: int = 64) -> None:
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(2, hidden), nn.ReLU(),
            nn.Linear(hidden, hidden), nn.ReLU(),
            nn.Linear(hidden, 2),                      # logits for 2 classes
        )
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)

model = MLP().to(device)
opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4)
loss_fn = nn.CrossEntropyLoss()

for epoch in range(10):
    model.train()                                      # enables dropout/batch-norm training behaviour
    for xb, yb in train_dl:
        xb, yb = xb.to(device), yb.to(device)
        logits = model(xb)                             # 1. forward
        loss = loss_fn(logits, yb)                     # 2. loss
        opt.zero_grad(set_to_none=True)                # 3. clear old gradients
        loss.backward()                                # 4. backprop
        opt.step()                                     # 5. update

    model.eval()
    correct = total = 0
    with torch.no_grad():                              # no graph building during evaluation
        for xb, yb in val_dl:
            pred = model(xb.to(device)).argmax(dim=1)
            correct += (pred == yb.to(device)).sum().item()
            total += len(yb)
    print(f"epoch {epoch}  train loss {loss.item():.4f}  val acc {correct / total:.3f}")

torch.save(model.state_dict(), "mlp.pt")              # weights only; see Ch 47.1 for loading safely

Every PyTorch training script, up to the LLM you train in AI Ch 41, has this shape: forward, loss, zero_grad, backward, step, plus an evaluation pass under torch.no_grad() and model.eval(). What changes for bigger models is around the loop, not the loop itself:

Concern Tool
speed on GPU mixed precision: torch.autocast(device, dtype=torch.bfloat16); torch.compile(model)
memory gradient accumulation, activation checkpointing, smaller batch, bfloat16
more than one GPU torch.distributed + DDP or FSDP, launched with torchrun
data loading DataLoader(num_workers=N, pin_memory=True) (processes, Ch 28; shared memory, Ch 37)
resuming save model and optimizer state, step, and RNG state; atomically (Ch 35)
tracking log loss curves (Ch 55, 62)

58.3 Saving models safely

from safetensors.torch import save_file, load_file   # uv add safetensors

save_file(model.state_dict(), "mlp.safetensors")     # tensors only: no code, mmap-able
state = load_file("mlp.safetensors")
model.load_state_dict(state)
# torch.load("third_party.pt")  -> defaults to weights_only=True since PyTorch 2.6; keep it that way

Generated from real-life-python-guide.md · 8 Oct 2026 16:36