Skip to content

03 · Advanced PyTorch Patterns

Modules 09 (Level 1) and 05 (Level 2) used simple in-memory tensors and plain training loops. Real projects need custom Datasets for data that doesn't fit in memory, learning rate schedules, and mixed precision for speed. This module covers the engineering patterns production PyTorch code actually uses.

Custom Dataset and DataLoader

import torch
from torch.utils.data import Dataset, DataLoader
import numpy as np

class CSVWindowDataset(Dataset):
    """Loads rows lazily and returns (features, label) pairs."""
    def __init__(self, n_rows=10000, n_features=20):
        rng = np.random.default_rng(42)
        self.X = rng.normal(size=(n_rows, n_features)).astype("float32")
        self.y = (self.X[:, 0] + self.X[:, 1] > 0).astype("int64")

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return torch.from_numpy(self.X[idx]), self.y[idx]

ds = CSVWindowDataset()
loader = DataLoader(ds, batch_size=64, shuffle=True, num_workers=2, pin_memory=True)
xb, yb = next(iter(loader))
print(xb.shape, yb.shape)   # torch.Size([64, 20]) torch.Size([64])

__len__ and __getitem__ are the only two methods required — DataLoader handles batching, shuffling, and (with num_workers > 0) parallel loading in background worker processes so the GPU is never left waiting on I/O.

Learning rate schedulers

A fixed learning rate is rarely optimal for the whole training run — large early on to make fast progress, small later to settle into a minimum.

model = torch.nn.Sequential(torch.nn.Linear(20, 32), torch.nn.ReLU(), torch.nn.Linear(32, 2))
optimizer = torch.optim.Adam(model.parameters(), lr=1e-2)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10)
loss_fn = torch.nn.CrossEntropyLoss()

for epoch in range(10):
    for xb, yb in loader:
        logits = model(xb)
        loss = loss_fn(logits, yb)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
    scheduler.step()               # called once per epoch, after all batches
    print(f"epoch {epoch}  lr={scheduler.get_last_lr()[0]:.5f}")
# epoch 0  lr=0.00976
# epoch 5  lr=0.00345
# epoch 9  lr=0.00001

CosineAnnealingLR smoothly decays the rate along a cosine curve from 1e-2 toward 0 over T_max epochs — no manual step-function tuning required.

Mixed precision training

Training in 16-bit floats instead of 32-bit roughly halves memory use and can significantly speed up GPU training, with autocast keeping numerically sensitive operations (like loss computation) in 32-bit automatically.

device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
scaler = torch.amp.GradScaler(enabled=(device == "cuda"))

for xb, yb in loader:
    xb, yb = xb.to(device), yb.to(device)
    optimizer.zero_grad()
    with torch.amp.autocast(device_type=device, enabled=(device == "cuda")):
        logits = model(xb)
        loss = loss_fn(logits, yb)
    scaler.scale(loss).backward()     # scales the loss to avoid underflow in fp16 gradients
    scaler.step(optimizer)
    scaler.update()

Worked example: gradient clipping against exploding gradients

model2 = torch.nn.Sequential(torch.nn.Linear(20, 128), torch.nn.ReLU(), torch.nn.Linear(128, 2))
optimizer2 = torch.optim.Adam(model2.parameters(), lr=0.5)   # deliberately too large

for xb, yb in loader:
    logits = model2(xb)
    loss = loss_fn(logits, yb)
    optimizer2.zero_grad()
    loss.backward()
    total_norm = torch.nn.utils.clip_grad_norm_(model2.parameters(), max_norm=1.0)
    optimizer2.step()
    break
print(f"gradient norm before clipping: {total_norm:.2f}")   # can be large, e.g. 14.7

clip_grad_norm_ rescales all gradients in place if their combined norm exceeds max_norm, preventing a single unstable batch from taking a destructively large step.

Cheat sheet

Pattern Code
Custom data source Subclass Dataset, implement __len__/__getitem__
Parallel data loading DataLoader(..., num_workers=N, pin_memory=True)
LR schedule torch.optim.lr_scheduler.CosineAnnealingLR/StepLR/OneCycleLR
Mixed precision torch.amp.autocast() + GradScaler
Prevent exploding gradients torch.nn.utils.clip_grad_norm_

How It Actually Works

DataLoader workers parallelize Python-level I/O and preprocessing, not GPU compute. With num_workers=2, PyTorch spawns separate worker processes, each independently calling __getitem__ on copies of the dataset object and assembling batches, which are then handed to the main process through inter-process queues. This matters because Python's GIL would otherwise serialize any CPU-bound work (file reads, image decoding, augmentation) with the main training loop; overlapping that work in separate processes means the next batch can be ready and waiting by the time the GPU finishes the current one, rather than the GPU sitting idle while Python assembles the next batch — pin_memory=True additionally allocates the batch in page-locked host memory, which the GPU can DMA-copy faster than regular pageable memory.

Cosine annealing is a literal cosine function mapped onto the learning rate axis, and its shape is why it works better than a linear decay in practice. CosineAnnealingLR sets lr(t) = lr_min + 0.5*(lr_max - lr_min)*(1 + cos(π * t / T_max)), which starts at lr_max when t=0, decreases slowly at first, accelerates through the middle, then flattens out again as it approaches lr_min near t=T_max. The flattening at both ends is the deliberate feature: near the start, a fast initial decay would waste the large steps still useful for covering distance toward a good region of parameter space; near the end, a slowly-flattening tail spends many epochs taking very small, careful steps that let the optimizer settle precisely into a minimum instead of overshooting it on the last few epochs — the same "smaller steps generalize better" argument from Module 02's gradient-boosting learning rate, applied to a schedule instead of a fixed constant.

Mixed precision saves memory and time by using fp16 for matrix multiplies while autocast and GradScaler protect against fp16's narrow representable range. fp16 uses half the bits of fp32, so tensors take half the memory and matrix multiplications (the dominant cost in any linear or attention layer) run faster on GPUs with dedicated fp16 hardware paths. The catch: fp16 has a much smaller exponent range than fp32, so very small gradient values can underflow to exactly zero during backward(). GradScaler addresses this mechanically by multiplying the loss by a large scale factor before backward() — this proportionally scales up every computed gradient by the chain rule (multiplying a function's output by a constant multiplies its gradient by the same constant), lifting small gradients back into fp16's representable range — then scaler.step() divides the gradients back down by that same factor before the actual optimizer update, so the update itself is mathematically unaffected. autocast separately keeps loss computation and other precision-sensitive reductions in fp32 automatically, since summing many fp16 values can introduce enough rounding error to destabilize training if left unmanaged.

Gradient norm clipping is a hard cap applied uniformly across all parameters, preserving gradient direction while limiting magnitude. clip_grad_norm_ computes the combined L2 norm across every parameter's gradient tensor (total_norm = sqrt(Σ ‖grad_p‖²) over all parameters p), and if that exceeds max_norm, rescales every gradient tensor by the same factor max_norm / total_norm. Because the same scalar multiplies every parameter's gradient, the relative proportions between different parameters' gradients — and therefore the overall descent direction — are preserved exactly; only the step's total size shrinks. This is mechanically why clipping fixes exploding-gradient instability (a single batch producing enormous gradients, common with large learning rates or deep recurrent architectures) without changing what direction training is moving in, unlike simply lowering the learning rate globally, which would shrink every step uniformly regardless of whether that particular batch needed it.

Exercise

Train model2 from the worked example for 5 full epochs both with and without clip_grad_norm_ (same lr=0.5, same data, same seed). Track the training loss after each epoch for both runs and report whether clipping prevents the divergence that an unclipped, deliberately-too-large learning rate would otherwise cause — connecting your observation to the "preserves direction, limits magnitude" mechanism above.