Getting Started
Installation & Tensors
PyTorch tensors are the core data structure. Use to(device) to move tensors to GPU. torch.randn returns random numbers from a normal distribution. Pick the right wheel URL for your CUDA version; CPU-only builds are smaller for development.
# install PyTorch (CUDA 11.8)
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
import torch
# create a tensor
x = torch.tensor([[1, 2], [3, 4]])
y = torch.zeros(3, 3)
z = torch.randn(2, 3) # standard normal
# tensor on GPU
device = "cuda" if torch.cuda.is_available() else "cpu"
x = x.to(device)Tensor Attributes
Every tensor has shape, dtype, and device attributes. Common dtypes: float32 (default), float16, int64, bool. numel() returns the total number of elements. Choose float16 for mixed precision to halve memory and speed up GPU compute.
x = torch.randn(3, 4, 5)
print(x.shape) # torch.Size([3, 4, 5])
print(x.dtype) # torch.float32
print(x.device) # cpu or cuda:0
print(x.requires_grad) # False
# specify dtype at creation
x = torch.zeros(3, dtype=torch.int64)
x = torch.tensor([1.0, 2.0], dtype=torch.float16)
# check number of elements
print(x.numel()) # 3Tensor Creation
torch.zeros/ones/full create constant tensors. arange and linspace create range tensors. rand is uniform, randn is normal. randint excludes the high bound. Use torch.manual_seed(42) before random ops for reproducibility.
# from Python lists
a = torch.tensor([1, 2, 3])
# special tensors
zeros = torch.zeros(2, 3)
ones = torch.ones(2, 3)
eye = torch.eye(3) # identity matrix
full = torch.full((2, 3), 7.0)
# ranges
arange = torch.arange(0, 10, 2) # [0, 2, 4, 6, 8]
linspace = torch.linspace(0, 1, 5) # [0, 0.25, 0.5, 0.75, 1.0]
# random
rand = torch.rand(2, 3) # uniform [0, 1)
randn = torch.randn(2, 3) # normal N(0, 1)
randint = torch.randint(0, 10, (3, 3))Type Conversion
to(dtype) converts tensor type. Shortcuts: .float(), .long(), .int(), .bool(). numpy() shares memory with the tensor (zero-copy on CPU). item() extracts a single Python scalar from a 0-d tensor. GPU tensors must be moved to CPU before numpy().
x = torch.tensor([1.5, 2.5, 3.5])
# change dtype
y = x.to(torch.int32) # tensor([1, 2, 3])
y = x.int() # shortcut
y = x.float()
y = x.bool()
y = x.long() # int64
# to numpy and back
arr = x.numpy() # shares memory (CPU)
t = torch.from_numpy(arr)
# 0-d tensor to Python scalar
val = x[0].item() # 1.5Indexing & Slicing
PyTorch indexing mirrors NumPy. Integer indexing returns a 0-d tensor (use .item() for a Python scalar). Boolean masks flatten the result. Indexing with a LongTensor selects along the first dimension. Advanced indexing creates copies; basic slicing creates views.
x = torch.arange(12).reshape(3, 4)
# tensor([[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]])
x[0] # first row
x[1, 2] # element at (1, 2) -> 6
x[:, 1] # second column
x[0:2] # first two rows
x[:, ::2] # every other column
# boolean mask
mask = x > 5
x[mask] # tensor([6, 7, 8, 9, 10, 11])
# index with LongTensor
idx = torch.tensor([0, 2])
x[idx] # rows 0 and 2Reshaping & Views
view requires contiguous memory; reshape works always (copies if needed). unsqueeze/squeeze add/remove size-1 dimensions. permute reorders all dimensions; transpose swaps two. reshape(-1) flattens. Operations like permute may make tensors non-contiguous, requiring .contiguous() before view().
x = torch.arange(12)
# reshape (returns view when possible)
y = x.reshape(3, 4)
y = x.view(3, 4) # only for contiguous tensors
# add/remove dimensions
y = x.unsqueeze(0) # shape (1, 12)
y = x.squeeze() # remove size-1 dims
# transpose / permute
a = torch.randn(3, 4, 5)
b = a.permute(2, 0, 1) # shape (5, 3, 4)
b = a.transpose(0, 1) # swap dims 0 and 1
# flatten
flat = a.flatten() # shape (60,)
flat = a.reshape(-1) # equivalentTensor Operations
Arithmetic Operations
Arithmetic operators (+, -, *, /) are element-wise. In-place operations end with _ and save memory but invalidate autograd history — avoid them on tensors that require grad. Broadcasting follows NumPy rules: trailing dimensions are matched or stretched to 1.
a = torch.tensor([1.0, 2.0, 3.0])
b = torch.tensor([4.0, 5.0, 6.0])
# element-wise ops
a + b # tensor([5., 7., 9.])
a * b # tensor([ 4., 10., 18.])
a / b # element-wise division
a ** 2 # tensor([1., 4., 9.])
# in-place (suffix _)
a.add_(1) # a = a + 1, modifies in place
a.mul_(2) # a = a * 2
# scalar ops
a + 10
b * 0.5Element-wise Math
Element-wise math mirrors torch.* and tensor.* methods. clamp is the PyTorch name for clipping. Use torch.finfo(x.dtype).eps instead of a hardcoded 1e-8 for a numerically safe log. Activation functions are also available as nn modules (nn.ReLU()) for models.
x = torch.tensor([-1.0, 0.0, 1.0, 2.0])
torch.abs(x) # absolute value
torch.sqrt(torch.abs(x))
torch.exp(x) # e^x
torch.log(x.abs() + 1e-8)
torch.sin(x)
torch.clamp(x, -0.5, 1.5) # clip values
# activation functions
torch.sigmoid(x) # 1 / (1 + e^-x)
torch.tanh(x)
torch.relu(x) # max(0, x)
# rounding
y = torch.tensor([1.4, 1.5, 2.6])
torch.round(y) # tensor([1., 2., 3.])
torch.floor(y)
torch.ceil(y)Reduction Operations
Reductions collapse one or more dimensions. Pass dim to choose the axis; keepdim=True keeps the reduced axis as size 1 (critical for broadcasting). max/min with a dim return a named tuple (values, indices). item() pulls a 0-d tensor to a Python scalar.
x = torch.arange(12).reshape(3, 4).float()
x.sum() # 66.0 (scalar tensor)
x.sum(dim=0) # sum over rows -> shape (4,)
x.sum(dim=1) # sum over columns -> shape (3,)
x.mean(dim=1)
x.max() # returns value tensor
x.max(dim=1) # returns (values, indices)
x.argmin(dim=1) # indices of min along dim
# keep dimensions
x.sum(dim=1, keepdim=True) # shape (3, 1)
# norm
x.norm() # Frobenius norm
x.norm(dim=1) # per-row normComparison Operations
Comparison operators return bool tensors. torch.equal checks exact equality and returns a Python bool. torch.where(cond, x, y) is the vectorized ternary — perfect for masking or selecting. topk returns both values and indices, useful for top-1 / top-5 accuracy.
a = torch.tensor([1, 2, 3, 4])
b = torch.tensor([3, 2, 1, 4])
a == b # tensor([False, True, False, True])
a > b # tensor([False, False, True, False])
a != b
torch.equal(a, b) # False (single bool)
# where: pick from two tensors
torch.where(a > b, a, b) # element-wise max
# boolean helpers
(a > 2).any() # True
(a > 2).all() # False
(a == b).sum() # 2 (count of True)
# top-k
torch.topk(a, 2) # returns top 2 values + indicesMatrix Operations
@ and torch.matmul do matrix multiplication and broadcast over batch dims. Use torch.bmm for strict 3D batched matmul (no broadcasting). einsum is expressive for multi-tensor contractions but can be slower than matmul on some hardware. Avoid inverting matrices when you can solve with torch.solve / linalg.solve instead.
a = torch.randn(2, 3)
b = torch.randn(3, 4)
# matrix multiplication
c = a @ b # shape (2, 4)
c = torch.matmul(a, b) # equivalent
# batched matmul
A = torch.randn(10, 2, 3)
B = torch.randn(10, 3, 4)
C = A @ B # shape (10, 2, 4)
# element-wise product
e = a * a # shape (2, 3)
# other ops
torch.inverse(a[:, :3]) # inverse of square slice
torch.det(a[:, :3])
torch.svd(a) # singular value decomposition
torch.einsum('bi,i->b', torch.randn(5,3), torch.randn(3)) # batched dotConcatenation & Stacking
cat joins tensors along an existing axis; stack adds a new axis. split divides by chunk size, chunk divides by number of pieces. expand creates a view with broadcasted data (no memory copy) — use it instead of repeat when possible. repeat physically duplicates data.
a = torch.randn(2, 3)
b = torch.randn(2, 3)
# concat along existing dim
c = torch.cat([a, b], dim=0) # shape (4, 3)
c = torch.cat([a, b], dim=1) # shape (2, 6)
# stack along new dim
s = torch.stack([a, b], dim=0) # shape (2, 2, 3)
# split / chunk
x = torch.arange(6).reshape(2, 3)
parts = torch.split(x, 2, dim=0) # split into chunks of size 2
chunks = torch.chunk(x, 3, dim=1) # split into 3 chunks
# repeat / expand
r = a.repeat(2, 3) # repeat whole tensor
e = a.unsqueeze(0).expand(4, 2, 3) # broadcast without copyAutograd
Basic Autograd
Set requires_grad=True to track operations for autograd. Calling .backward() on a scalar tensor computes gradients of that tensor w.r.t. all leaf tensors with requires_grad=True. Gradients accumulate into .grad — call optimizer.zero_grad() or x.grad=None between iterations.
import torch
# tensors that require gradient tracking
x = torch.tensor([2.0], requires_grad=True)
y = torch.tensor([3.0], requires_grad=True)
# build a computation graph
z = x * y + x ** 2 # z = xy + x^2
print(z.grad_fn) # <AddBackward0 object>
# backprop
z.backward()
# dz/dx = y + 2x = 3 + 4 = 7
print(x.grad) # tensor([7.])
# dz/dy = x = 2
print(y.grad) # tensor([2.])Computing Gradients
backward() only works on scalar outputs; for tensors, sum or mean first. torch.autograd.grad returns gradients as a tuple without storing them in .grad. This is preferred inside libraries or when you need gradients of non-leaf tensors.
import torch
x = torch.linspace(-3, 3, steps=10, requires_grad=True)
y = torch.sin(x)
# sum needed for backward on non-scalar
y.sum().backward()
print(x.grad) # cos(x)
# gradient w.r.t. intermediate tensor
a = torch.tensor(1.0, requires_grad=True)
b = a * 2
c = b ** 2
grads = torch.autograd.grad(c, a)
print(grads) # (tensor(8.),) dc/da = 4b * 2 = 8Disabling Gradient Tracking
Use torch.no_grad() for inference to skip graph construction — saves memory and time. .detach() returns a tensor that shares data but is cut from the graph; great for logging or feeding tensors to non-PyTorch code. model.eval() switches layers (dropout, BN) to inference mode but does NOT disable autograd — combine with torch.no_grad().
x = torch.tensor([1.0], requires_grad=True)
# option 1: context manager (preferred)
with torch.no_grad():
y = x * 2 # y.requires_grad == False
# option 2: decorator
@torch.no_grad()
def inference(x):
return model(x)
# option 3: detach from graph
y = (x * 2).detach() # y is a new tensor, no grad
# enable grad inside no_grad
with torch.no_grad():
with torch.enable_grad():
z = x * 3 # z requires gradHigher-Order Gradients
create_graph=True keeps the graph of the gradient itself so you can differentiate again. Useful for gradient penalties (WGAN-GP), meta-learning, and physics-informed losses. It costs extra memory — free intermediate graphs with retain_graph=False when done.
x = torch.tensor(0.5, requires_grad=True)
# first derivative
y = torch.sin(x)
y.backward(create_graph=True)
print(x.grad) # cos(0.5)
# second derivative (gradient of gradient)
x.grad.zero_()
g = torch.autograd.grad(torch.sin(x), x, create_graph=True)[0]
g2 = torch.autograd.grad(g, x)[0]
print(g2) # -sin(0.5)
# practical use: penalize gradient (e.g. gradient penalty)
loss = g.norm()Gradient Hooks
register_hook on a tensor lets you inspect or modify its gradient during backward — handy for debugging NaNs or implementing gradient clipping on specific tensors. register_full_backward_hook on a module sees both input and output gradients. Always return a modified gradient if you want changes to propagate.
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = (x ** 2).sum()
# full backward hook on a tensor
def print_grad(grad):
print("grad:", grad)
return grad * 2 # can modify gradient
y.register_hook(print_grad)
y.backward()
print(x.grad) # 2x but doubled by hook
# module hook
def hook_fn(module, grad_input, grad_output):
print(module.__class__.__name__, grad_output)
model.layer.register_full_backward_hook(hook_fn)Computational Graph Details
Leaf tensors are user-created (requires_grad=True, no grad_fn). Intermediate results are non-leaf. By default the graph is freed after backward(); pass retain_graph=True to call backward multiple times. create_graph=True retains the gradient graph for higher-order derivatives. Memory grows quickly — clear with x.grad=None between iterations.
x = torch.tensor(2.0, requires_grad=True)
w = torch.tensor(3.0, requires_grad=True)
b = torch.tensor(1.0, requires_grad=True)
y = w * x + b
print(y.is_leaf) # False
print(x.is_leaf) # True
# retain graph for multiple backward passes
loss = (y - 5) ** 2
loss.backward(retain_graph=True)
# can call backward again
loss.backward()
# double backward
loss.backward(create_graph=True)
print(x.grad)Data Loading
Dataset & DataLoader
DataLoader batches, shuffles, and parallel-loads a Dataset. num_workers>0 uses subprocesses — set 2-8 per GPU. pin_memory=True speeds up CPU→GPU transfer. For small datasets that fit in RAM, set num_workers=0 to avoid overhead.
from torch.utils.data import Dataset, DataLoader
# built-in datasets
from torchvision import datasets
mnist = datasets.MNIST("./data", train=True, download=True)
# wrap in DataLoader
loader = DataLoader(
mnist,
batch_size=64,
shuffle=True,
num_workers=4,
pin_memory=True,
)
for x, y in loader:
print(x.shape, y.shape) # (64, 1, 28, 28), (64,)
breakBatch Sampling
Pass a batch_sampler to control exactly which indices form each batch. WeightedRandomSampler rebalances imbalanced classes by oversampling rare ones. drop_last=True drops the final short batch — important for BatchNorm stability. Samplers iterate over indices; the DataLoader fetches the actual data.
from torch.utils.data import DataLoader, BatchSampler, RandomSampler
dataset = list(range(100))
sampler = RandomSampler(dataset)
batch_sampler = BatchSampler(sampler, batch_size=10, drop_last=True)
loader = DataLoader(dataset, batch_sampler=batch_sampler)
# custom sampler
class EvenSampler:
def __init__(self, data): self.data = data
def __iter__(self): return iter(range(0, len(self.data), 2))
def __len__(self): return len(self.data) // 2
# weighted sampling for imbalanced classes
from torch.utils.data import WeightedRandomSampler
weights = [0.1] * 50 + [1.0] * 50
sampler = WeightedRandomSampler(weights, num_samples=100)Shuffling & Sampling
shuffle=True randomizes order each epoch (good for training); keep validation shuffle=False for reproducible metrics. random_split is convenient but its RNG is not seedable directly — use Subset with torch.randperm and a Generator for full control. Subset is just a thin index view, no data copy.
from torch.utils.data import DataLoader, random_split
# train/val split
train, val = random_split(dataset, [80, 20])
train_loader = DataLoader(train, batch_size=16, shuffle=True)
val_loader = DataLoader(val, batch_size=32, shuffle=False)
# reproducible split
from torch.utils.data import Subset
import numpy as np
gen = torch.Generator().manual_seed(42)
idx = torch.randperm(len(dataset), generator=gen).tolist()
train_idx, val_idx = idx[:80], idx[80:]
train, val = Subset(dataset, train_idx), Subset(dataset, val_idx)Custom Collate
collate_fn turns a list of samples into a batch. The default stacks tensors along a new dim 0. Override it for variable-length sequences (pad then sort by length for pack_padded_sequence), dict-based samples, or mixed tensor/non-tensor data. Keep the function cheap — it runs on the data-loading workers.
from torch.utils.data import default_collate
# variable-length sequences: pad in collate
def pad_collate(batch):
# batch is list of (tensor, label)
seqs, labels = zip(*batch)
lens = torch.tensor([len(s) for s in seqs])
max_len = lens.max()
padded = torch.zeros(len(seqs), max_len)
for i, s in enumerate(seqs):
padded[i, :len(s)] = s
return padded, lens, torch.tensor(labels)
loader = DataLoader(dataset, batch_size=4, collate_fn=pad_collate)
# stack batch as dict
def dict_collate(batch):
return default_collate(batch)Parallel Loading
num_workers parallelizes data loading so the GPU never waits. persistent_workers=True avoids the per-epoch worker startup cost. prefetch_factor (default 2) controls how many batches each worker prepares ahead. On Windows and Jupyter, multiprocessing can be flaky — fall back to num_workers=0 to debug. Watch out: each worker inherits the full process state, including open file handles and CUDA contexts.
loader = DataLoader(
dataset,
batch_size=128,
num_workers=8, # one process per worker
pin_memory=True, # page-locked memory for fast H2D copy
persistent_workers=True, # keep workers alive across epochs
prefetch_factor=4, # batches prefetched per worker
)
# avoid CPU bottleneck
import os
os.cpu_count() # check available cores
# in Jupyter / Windows: set start method
import torch.multiprocessing as mp
# mp.set_start_method('spawn') # uncomment if needednn.Module
Defining a Module
Subclass nn.Module and implement forward(). Declare sub-modules and parameters as attributes so they are registered automatically (this is what enables .to(device), .parameters(), and state_dict). Always call super().__init__(). Avoid arbitrary Python logic in forward — keep it differentiable and tensor-only.
import torch.nn as nn
class MLP(nn.Module):
def __init__(self, in_dim, hidden, out_dim):
super().__init__()
self.fc1 = nn.Linear(in_dim, hidden)
self.fc2 = nn.Linear(hidden, hidden)
self.fc3 = nn.Linear(hidden, out_dim)
self.act = nn.ReLU()
def forward(self, x):
x = self.act(self.fc1(x))
x = self.act(self.fc2(x))
return self.fc3(x) # logits
model = MLP(784, 128, 10)
print(model) # prints structureLinear & Activation Layers
nn.Linear applies y = xW^T + b. Weight shape is (out, in). Prefer activation modules when you want them registered (state_dict, hooks); use functional F.* for one-off ops. GELU and SwiGLU-style activations dominate transformer architectures. Softmax should usually be omitted from the model and handled by CrossEntropyLoss.
import torch.nn as nn
linear = nn.Linear(in_features=20, out_features=10, bias=True)
print(linear.weight.shape) # (10, 20)
print(linear.bias.shape) # (10,)
# common activations
nn.ReLU() # max(0, x)
nn.LeakyReLU(0.01)
nn.GELU() # smooth, used in transformers
nn.Sigmoid() # (0, 1)
nn.Tanh() # (-1, 1)
nn.Softmax(dim=1)
nn.ELU()
# functional API (no parameters)
import torch.nn.functional as F
out = F.relu(linear(x)) # same as nn.ReLU()(linear(x))Parameters & Submodules
named_parameters() yields (name, tensor) for the whole tree — great for freezing, weight decay, or debugging. .modules() recurses into children; .children() is one level only. Buffers (e.g. BatchNorm running stats) show up in state_dict but not parameters(); use named_buffers() to see them.
model = MLP(784, 128, 10)
# iterate parameters
for name, p in model.named_parameters():
print(name, p.shape, p.requires_grad)
# only trainable params
trainable = filter(lambda p: p.requires_grad, model.parameters())
# count parameters
n_params = sum(p.numel() for p in model.parameters())
print(f"{n_params:,} parameters")
# access submodules by name
model.fc1 # the Linear layer
model['fc1'] # equivalent if using nn.ModuleDict
# children vs modules
list(model.children()) # direct children
list(model.modules()) # recursively all sub-modulesSequential Models
nn.Sequential chains modules where each output feeds the next input — perfect for linear pipelines. Use OrderedDict to name stages so you can index by attribute (model.fc1). For models with branches, skip connections, or multiple inputs/outputs, subclass nn.Module and write forward() explicitly.
import torch.nn as nn
# simple stack
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(256, 10),
)
# named layers via OrderedDict
from collections import OrderedDict
model = nn.Sequential(OrderedDict([
('fc1', nn.Linear(784, 256)),
('relu', nn.ReLU()),
('dropout', nn.Dropout(0.5)),
('fc2', nn.Linear(256, 10)),
]))
print(model.fc1) # access by name
# append at runtime
model.append(nn.Softmax(dim=1))Module Methods
to(device) moves parameters and buffers; .double()/.half()/.float() cast dtypes. train()/eval() toggle the self.training flag — Dropout and BatchNorm branch on it. state_dict() returns an OrderedDict of all parameters and buffers; load_state_dict restores them. .apply(fn) recurses into all submodules, useful for custom initialization.
model = MLP(784, 128, 10)
# move to device / dtype
model = model.to('cuda')
model = model.to(torch.float16)
# train vs eval mode
model.train() # enable dropout, update BN stats
model.eval() # disable dropout, freeze BN stats
# state dict
sd = model.state_dict()
model.load_state_dict(sd)
# apply a function to all submodules
model.apply(lambda m: print(type(m).__name__))
# set requires_grad on all parameters
for p in model.parameters():
p.requires_grad_(False)Initialization
PyTorch uses Kaiming He init for Linear/Conv by default, which works for most cases. Override with torch.nn.init functions (suffix _ means in-place). Kaiming suits ReLU-family; Xavier/Glorot suits tanh/sigmoid. Initializing biases to zero is standard. .apply(fn) runs fn on every submodule — perfect for bulk initialization or weight reset.
import torch.nn as nn
import torch.nn.init as init
class Net(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(100, 50)
self.fc2 = nn.Linear(50, 10)
self._init_weights()
def _init_weights(self):
init.kaiming_normal_(self.fc1.weight, nonlinearity='relu')
init.zeros_(self.fc1.bias)
init.xavier_uniform_(self.fc2.weight)
init.zeros_(self.fc2.bias)
# apply to existing model
def init_all(m):
if isinstance(m, nn.Linear):
init.kaiming_normal_(m.weight)
if m.bias is not None:
init.zeros_(m.bias)
model.apply(init_all)Loss Functions
CrossEntropyLoss
CrossEntropyLoss applies log-softmax + NLLLoss internally — pass raw logits, never softmax outputs. Targets are integer class indices (not one-hot). Use weight for imbalanced classes and ignore_index=-100 to mask padding tokens. label_smoothing=0.1 is a popular regularizer for classification and NLP.
import torch
import torch.nn as nn
# logits shape (N, C), targets shape (N,) with class indices
logits = torch.randn(4, 3) # 4 samples, 3 classes
targets = torch.tensor([0, 2, 1, 0])
criterion = nn.CrossEntropyLoss()
loss = criterion(logits, targets)
# equivalently, combine LogSoftmax + NLLLoss
log_softmax = nn.LogSoftmax(dim=1)
nll = nn.NLLLoss()
loss2 = nll(log_softmax(logits), targets)
# weight classes (handle imbalance)
weights = torch.tensor([1.0, 2.0, 1.0])
criterion = nn.CrossEntropyLoss(weight=weights)
# ignore padding index (NLP)
criterion = nn.CrossEntropyLoss(ignore_index=-100)MSELoss & L1Loss
MSELoss penalizes large errors quadratically (sensitive to outliers); L1 is linear and more robust. SmoothL1Loss behaves like L2 near zero and L1 far away — used in object detection (Fast R-CNN). reduction='none' returns a per-element tensor so you can mask or weight it yourself before calling .mean().
import torch.nn as nn
pred = torch.randn(4, 1)
target = torch.randn(4, 1)
# mean squared error (L2)
mse = nn.MSELoss()
loss = mse(pred, target) # mean over all elements
# L1 (mean absolute error)
l1 = nn.L1Loss()
loss = l1(pred, target)
# smooth L1 (Huber) — robust to outliers
smooth = nn.SmoothL1Loss(beta=0.1)
# reduction options
mse_sum = nn.MSELoss(reduction='sum')
mse_none = nn.MSELoss(reduction='none') # per-elementBCELoss & BCEWithLogitsLoss
Always prefer BCEWithLogitsLoss over BCELoss+sigmoid — it uses the log-sum-exp trick for numerical stability. pos_weight multiplies the positive term to counter class imbalance (like focal loss lite). For multi-label classification (each class independent), use BCEWithLogitsLoss with shape (N, C) and sigmoid outputs.
import torch.nn as nn
# binary classification, multi-label, or sigmoid outputs
logits = torch.randn(4, 1)
target = torch.tensor([[1.0], [0.0], [1.0], [0.0]])
# preferred: combines sigmoid + BCE, numerically stable
criterion = nn.BCEWithLogitsLoss()
loss = criterion(logits, target)
# BCELoss expects probabilities (apply sigmoid first)
criterion = nn.BCELoss()
probs = torch.sigmoid(logits)
loss = criterion(probs, target)
# positive weight for imbalanced binary tasks
pos_weight = torch.tensor([5.0])
criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)Custom Loss Function
A custom loss is just a function (or nn.Module) that returns a scalar tensor — autograd handles the rest. Make every operation differentiable (torch.where with differentiable branches is fine). Module form is preferred when you need hyperparameters, serialization, or to register it in a config. Keep reduction explicit (mean/sum/none) so behavior is predictable.
import torch
import torch.nn as nn
import torch.nn.functional as F
# as a function
def huber_loss(pred, target, delta=1.0):
error = pred - target
abs_err = error.abs()
quad = torch.where(abs_err <= delta,
0.5 * error ** 2,
delta * (abs_err - 0.5 * delta))
return quad.mean()
# as a module (so it can have parameters / state)
class FocalLoss(nn.Module):
def __init__(self, alpha=0.25, gamma=2.0):
super().__init__()
self.alpha = alpha
self.gamma = gamma
def forward(self, logits, targets):
bce = F.binary_cross_entropy_with_logits(logits, targets, reduction='none')
p = torch.sigmoid(logits)
pt = p * targets + (1 - p) * (1 - targets)
loss = self.alpha * (1 - pt) ** self.gamma * bce
return loss.mean()
criterion = FocalLoss()Loss Reduction & Combining
reduction='none' gives per-sample losses so you can mask, reweight, or aggregate manually. Combine multi-task losses by weighted sum — the weights balance gradients, not just magnitudes (tune them). Each sub-loss must stay differentiable and on the same device/dtype. Detach a loss term with .detach() if you want to log it without backpropagating through it.
import torch.nn as nn
import torch
criterion = nn.CrossEntropyLoss(reduction='none')
loss_per = criterion(logits, targets) # shape (N,)
loss = loss_per.mean()
# class-wise mask
mask = targets != -100
loss = criterion(logits[mask], targets[mask])
# multi-task loss
loss_cls = nn.CrossEntropyLoss()(logits, labels)
loss_box = nn.L1Loss()(boxes_pred, boxes_gt)
total = 1.0 * loss_cls + 0.5 * loss_box
# gradient-weighted
total = loss_cls + 0.5 * loss_box
total.backward()NLLLoss & Other Losses
NLLLoss + LogSoftmax is mathematically identical to CrossEntropyLoss but lets you reuse the log-probs. TripletMarginLoss trains metric-learning embeddings. KLDivLoss expects log-probs as input and probs as target. CTCLoss aligns unsegmented sequences to targets — pass input_lengths and target_lengths to avoid runtime errors.
import torch.nn as nn
# NLLLoss expects log-probabilities (use LogSoftmax first)
log_probs = torch.log_softmax(logits, dim=1)
loss = nn.NLLLoss()(log_probs, targets)
# triplet loss for embeddings
anchor = torch.randn(4, 128)
positive = torch.randn(4, 128)
negative = torch.randn(4, 128)
loss = nn.TripletMarginLoss(margin=1.0)(anchor, positive, negative)
# KL divergence (distributions)
loss = nn.KLDivLoss(reduction='batchmean')(log_probs, target_dist)
# cosine embedding loss
loss = nn.CosineEmbeddingLoss()(x1, x2, torch.tensor([1, -1, 1, -1]))
# CTCLoss for sequence alignment (ASR/OCR)
loss = nn.CTCLoss(blank=0, zero_infinity=True)(log_probs, targets, in_lens, out_lens)Optimizers
SGD
SGD + momentum is still a top choice for large-batch CNN training and generalizes better than Adam on some tasks. Momentum 0.9 is standard; Nesterov gives a small extra edge. weight_decay adds L2 regularization. SGD needs careful LR scheduling and warmup; it is not auto-tuning like Adam.
import torch.optim as optim
# basic SGD
optimizer = optim.SGD(model.parameters(), lr=0.01)
# SGD with momentum (classic: 0.9)
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# Nesterov momentum
optimizer = optim.SGD(
model.parameters(),
lr=0.01,
momentum=0.9,
nesterov=True,
)
# weight decay (L2 regularization)
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)Adam & AdamW
Adam stores per-parameter moments, so it adapts the step size automatically — great default for NLP and small datasets. AdamW decouples weight decay from the gradient update, which matters at scale (transformers). betas=(0.9, 0.999) is standard; some vision models use (0.9, 0.95). Use per-parameter groups for differential learning rates in transfer learning.
import torch.optim as optim
# Adam — adaptive, default-friendly
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# AdamW — decoupled weight decay (preferred for transformers)
optimizer = optim.AdamW(
model.parameters(),
lr=3e-4,
betas=(0.9, 0.999),
weight_decay=0.01,
)
# per-parameter group settings
optimizer = optim.Adam([
{'params': model.encoder.parameters(), 'lr': 1e-4},
{'params': model.head.parameters(), 'lr': 1e-3},
], lr=1e-3)Optimizer Step Pattern
The 3-step pattern — zero_grad, backward, step — is the heart of PyTorch training. set_to_none=True (default in newer versions) is faster than filling with zeros because it skips a memset and lets the optimizer skip the param. Manually edit p.grad.data between backward() and step() for clipping or custom updates.
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# standard training step
optimizer.zero_grad() # clear old grads
loss = criterion(model(x), y)
loss.backward() # compute gradients
optimizer.step() # update parameters
# set gradients to None (faster, recommended)
optimizer.zero_grad(set_to_none=True)
# manual gradient modification
for p in model.parameters():
if p.grad is not None:
p.grad.data.clamp_(-1, 1) # gradient clipping
optimizer.step()Gradient Clipping
clip_grad_norm_ rescales gradients if their global L2 norm exceeds max_norm — a stable default for RNNs and transformers. clip_grad_value_ hard-clamps each element (less common). Clipping prevents exploding gradients without changing direction. Log the pre-clip grad norm to detect instability early (sudden spikes > 100 indicate problems).
# gradient norm clipping (most common)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# gradient value clipping
torch.nn.utils.clip_grad_value_(model.parameters(), clip_value=0.5)
# in a training loop
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
# inspect gradient norm
total_norm = torch.norm(torch.stack([
p.grad.norm(2) for p in model.parameters() if p.grad is not None
]), 2)Learning Rate & Schedules
Each param_group has its own lr — iterate to set all. Manual warmup linearly ramps LR for the first N steps to stabilize early training (critical for transformers without bias correction). Use a scheduler object (next section) for cosine decay, OneCycle, etc. Always step the scheduler after optimizer.step() unless the docs say otherwise.
import torch.optim as optim
# get / set learning rate
for g in optimizer.param_groups:
print(g['lr'])
g['lr'] = 1e-4
# manual warmup
for step in range(warmup_steps):
lr = base_lr * (step + 1) / warmup_steps
for g in optimizer.param_groups:
g['lr'] = lr
# optimizer also accepts schedulers (see lr-scheduler section)
from torch.optim.lr_scheduler import StepLR
scheduler = StepLR(optimizer, step_size=10, gamma=0.1)Other Optimizers
RMSprop is the classic RNN optimizer and still works well. Adagrad accumulates squared gradients and can stall early — fine for sparse features but not deep nets. LBFGS needs a closure and full-batch evaluation — great for small convex problems or fine-tuning, too slow for large deep nets. For most new projects, start with AdamW.
import torch.optim as optim
# RMSprop — good for RNNs
optim.RMSprop(model.parameters(), lr=1e-3, alpha=0.99)
# Adagrad — adapts per-parameter, accumulates squared grads
optim.Adagrad(model.parameters(), lr=1e-2)
# Adadelta — Adagrad variant with running average
optim.Adadelta(model.parameters(), rho=0.9)
# LBFGS — full-batch quasi-Newton, second-order
optim.LBFGS(model.parameters(), lr=1.0, max_iter=20)
# NAdam — Nesterov-accelerated Adam
optim.NAdam(model.parameters(), lr=1e-3)Training Loop
Basic Training Loop
The minimal training loop: zero_grad, forward, loss, backward, step. Move data to GPU inside the loop (async overlap with num_workers). loss.item() pulls a scalar to Python — do this for logging, not for the loss tensor itself (you would break the graph). Wrap the loop in a function so you can reuse it.
model = MLP(784, 128, 10).to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
for epoch in range(epochs):
model.train()
for x, y in train_loader:
x, y = x.to(device), y.to(device)
optimizer.zero_grad()
logits = model(x)
loss = criterion(logits, y)
loss.backward()
optimizer.step()
print(f"epoch {epoch}, loss={loss.item():.4f}")Validation in Loop
model.eval() + torch.no_grad() are both required: eval switches dropout/BN, no_grad skips the graph. Accumulate weighted loss (multiply by batch size) so the average is correct even for the last short batch. argmax(dim=1) gives predicted class indices. Save the best model when val metric improves (see save-load section).
for epoch in range(epochs):
model.train()
for x, y in train_loader:
# ... training step ...
# validation
model.eval()
val_loss, correct, total = 0.0, 0, 0
with torch.no_grad():
for x, y in val_loader:
x, y = x.to(device), y.to(device)
logits = model(x)
val_loss += criterion(logits, y).item() * x.size(0)
correct += (logits.argmax(1) == y).sum().item()
total += x.size(0)
print(f"val_loss={val_loss/total:.4f}, acc={correct/total:.4f}")Gradient Accumulation
Gradient accumulation simulates a larger batch by summing gradients over several mini-batches before stepping. Divide the loss by accum_steps so the effective gradient matches a single large batch. Make sure the dataset length is divisible by accum_steps, or step on the leftover gradients at epoch end. Especially useful for transformer fine-tuning on a single GPU.
accum_steps = 4
optimizer.zero_grad()
for i, (x, y) in enumerate(train_loader):
x, y = x.to(device), y.to(device)
loss = criterion(model(x), y) / accum_steps
loss.backward()
if (i + 1) % accum_steps == 0:
optimizer.step()
optimizer.zero_grad()
# equivalent effective batch = accum_steps * batch_size
# useful when GPU memory limits batch sizeMixed Precision Training
Mixed precision runs forward ops in float16 (or bfloat16 on Ampere+) for 1.5-3x speedup and halved memory. GradScaler multiplies the loss to prevent float16 gradient underflow, then unscales before optimizer.step(). On H100/A100 prefer bfloat16 — it has the same range as float32 and does not need a scaler. The autocast context manager only affects the forward pass.
from torch.amp import autocast, GradScaler
scaler = GradScaler('cuda')
for x, y in train_loader:
x, y = x.to(device), y.to(device)
optimizer.zero_grad()
with autocast('cuda'):
logits = model(x)
loss = criterion(logits, y)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
# autocast casts select ops to float16/bf16 for speed
# GradScaler prevents underflow by scaling the lossProgress & Logging
tqdm shows a live progress bar — use set_postfix to display the running loss. An exponential moving average (running = 0.9*running + 0.1*loss) smooths noise better than the raw last-batch value. For serious runs, log to TensorBoard or Weights & Biases instead of stdout so you can compare runs. Avoid logging every step if it slows training.
from tqdm import tqdm
for epoch in range(epochs):
model.train()
pbar = tqdm(train_loader, desc=f"epoch {epoch}")
running = 0.0
for i, (x, y) in enumerate(pbar):
x, y = x.to(device), y.to(device)
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
running = 0.9 * running + 0.1 * loss.item()
pbar.set_postfix(loss=f"{running:.4f}")
# TensorBoard logging (see visualization section)
writer.add_scalar('train/loss', loss.item(), global_step)Reproducibility
Full reproducibility needs seeds for Python, NumPy, PyTorch CPU and CUDA. cudnn.deterministic=True disables non-deterministic algorithms (some conv backward ops). DataLoader workers each need seeding via worker_init_fn because they fork with their own RNG state. Even then, multi-GPU and some cuDNN ops may not be bit-reproducible — aim for statistical reproducibility.
import torch, numpy as np, random
def set_seed(seed=42):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
# cudnn determinism (may slow down)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
set_seed(42)
# DataLoader worker seeding
def seed_worker(worker_id):
worker_seed = torch.initial_seed() % 2**32
np.random.seed(worker_seed)
random.seed(worker_seed)
g = torch.Generator().manual_seed(42)
loader = DataLoader(ds, batch_size=32, worker_init_fn=seed_worker, generator=g)Evaluation & Metrics
Eval Mode & No Grad
Always pair model.eval() with torch.no_grad() for evaluation. eval() flips dropout and BatchNorm to inference mode (BN uses running stats); no_grad() disables autograd to save memory and time. Multiply loss by batch size when accumulating so the final average is unbiased even if the last batch is short.
model.eval()
test_loss, correct, total = 0.0, 0, 0
with torch.no_grad():
for x, y in test_loader:
x, y = x.to(device), y.to(device)
logits = model(x)
test_loss += criterion(logits, y).item() * x.size(0)
preds = logits.argmax(dim=1)
correct += (preds == y).sum().item()
total += x.size(0)
print(f"test_loss={test_loss/total:.4f}, acc={correct/total:.4f}")Accuracy & Top-k
argmax(dim=1) returns predicted class per row. topk returns the k largest values and their indices — equating them to targets expanded to (N,1) and using .any(dim=1) gives top-k correctness. Per-class accuracy exposes imbalance problems that overall accuracy hides. Always move tensors to CPU and call .item() before passing to print or log.
logits = torch.randn(8, 10)
targets = torch.randint(0, 10, (8,))
# top-1 accuracy
preds = logits.argmax(dim=1)
acc = (preds == targets).float().mean().item()
# top-k accuracy
topk = 3
_, idx = logits.topk(topk, dim=1) # shape (N, k)
correct = idx.eq(targets.view(-1, 1)).any(dim=1)
topk_acc = correct.float().mean().item()
# per-class accuracy
for c in range(10):
mask = targets == c
print(c, (preds[mask] == targets[mask]).float().mean().item())Confusion Matrix
Accumulate predictions on GPU, then move to CPU once at the end to avoid per-batch syncs. confusion_matrix shows where the model confuses classes — perfect for debugging. classification_report gives precision, recall, and F1 per class. For very large datasets, stream predictions to disk instead of holding them in memory.
from sklearn.metrics import confusion_matrix, classification_report
import numpy as np
all_preds, all_labels = [], []
model.eval()
with torch.no_grad():
for x, y in test_loader:
logits = model(x.to(device))
all_preds.append(logits.argmax(1).cpu())
all_labels.append(y)
preds = torch.cat(all_preds).numpy()
labels = torch.cat(all_labels).numpy()
cm = confusion_matrix(labels, preds)
print(cm)
print(classification_report(labels, preds))Precision, Recall & F1
torchmetrics accumulates state across batches and computes the metric correctly even with class imbalance or changing batch sizes. average='macro' treats all classes equally; 'weighted' weights by support; 'micro' equals accuracy for single-label tasks. Call .reset() between epochs or evaluations to clear the accumulator.
from torchmetrics import Accuracy, Precision, Recall, F1Score
acc = Accuracy(task='multiclass', num_classes=10).to(device)
prec = Precision(task='multiclass', num_classes=10, average='macro').to(device)
rec = Recall(task='multiclass', num_classes=10, average='macro').to(device)
f1 = F1Score(task='multiclass', num_classes=10, average='macro').to(device)
model.eval()
with torch.no_grad():
for x, y in test_loader:
logits = model(x.to(device))
preds = logits.argmax(1)
acc.update(preds, y.to(device))
prec.update(preds, y.to(device))
print(acc.compute(), prec.compute(), f1.compute())Cross-Validation
K-fold cross-validation gives a robust estimate of model performance and its variance. Re-instantiate the model per fold so weights don't leak across folds. For large datasets, 5 folds is usually enough; for small ones, use stratified splits (StratifiedKFold) to preserve class balance. Report mean +/- std so readers see stability.
from sklearn.model_selection import KFold
kfold = KFold(n_splits=5, shuffle=True, random_state=42)
fold_accs = []
for fold, (train_idx, val_idx) in enumerate(kfold.split(dataset)):
train = torch.utils.data.Subset(dataset, train_idx)
val = torch.utils.data.Subset(dataset, val_idx)
model = build_model() # fresh model per fold
train_model(model, train) # your training function
acc = evaluate(model, val)
fold_accs.append(acc)
print(f"fold {fold}: acc={acc:.4f}")
print(f"mean={np.mean(fold_accs):.4f} +/- {np.std(fold_accs):.4f}")Model Comparison
Always compare models on the same held-out test set with the same seed and metric. Beyond accuracy, look at calibration, inference latency, and memory. Use bootstrap confidence intervals when test-set differences are small. Keep a results table (CSV or W&B) so you can compare against future experiments instead of just the last run.
results = {}
for name, model in models.items():
model.eval()
accs, losses = [], []
with torch.no_grad():
for x, y in test_loader:
x, y = x.to(device), y.to(device)
logits = model(x)
losses.append(criterion(logits, y).item())
accs.append((logits.argmax(1) == y).float().mean().item())
results[name] = {
'acc': np.mean(accs),
'loss': np.mean(losses),
}
for name, r in sorted(results.items(), key=lambda kv: -kv[1]['acc']):
print(f"{name:20s} acc={r['acc']:.4f} loss={r['loss']:.4f}")Save & Load
Save & Load State Dict
state_dict() is an OrderedDict of parameter tensors — saving it (not the whole model) is the recommended pattern because it is portable and decoupled from the exact class/file layout. map_location lets you load a CUDA checkpoint on CPU. strict=False loads only matching keys, handy when the architecture changed slightly.
import torch
# save (recommended: only state_dict)
torch.save(model.state_dict(), 'model.pth')
# load
model = MLP(784, 128, 10) # must match the saved architecture
model.load_state_dict(torch.load('model.pth', map_location='cpu'))
model.eval()
# load on GPU that was trained on GPU
model.load_state_dict(torch.load('model.pth'))
model.to(device)
# strict=False allows partial loading (transfer learning)
model.load_state_dict(torch.load('model.pth'), strict=False)Save Entire Model
Saving the whole model pickles the Python object, binding it to the exact class and file structure at save time. This is brittle — any refactor breaks loading. Use it only for quick experiments. For anything you might deploy or share, save state_dict and keep the architecture in code or config.
# save entire model (uses pickle) — NOT recommended
torch.save(model, 'model_full.pth')
# load
model = torch.load('model_full.pth', map_location='cpu')
model.eval()
# pickle limitations:
# - class definition must be importable at load time
# - breaks if you refactor or rename the class
# - not portable across PyTorch versions
# prefer state_dict for productionTraining Checkpoints
A full checkpoint stores everything needed to resume training exactly: model, optimizer, scheduler, epoch, and RNG states. Optimizer state (momentum buffers, Adam moments) is essential — resuming without it effectively reinitializes the optimizer and can spike the loss. Save RNG state only if you need bit-exact reproducibility.
checkpoint = {
'epoch': epoch,
'model_state': model.state_dict(),
'optimizer_state': optimizer.state_dict(),
'scheduler_state': scheduler.state_dict() if scheduler else None,
'loss': loss.item(),
'rng_state': torch.get_rng_state(),
'cuda_rng_state': torch.cuda.get_rng_state_all(),
}
torch.save(checkpoint, f'ckpt_epoch{epoch}.pth')
# resume
ckpt = torch.load('ckpt_epoch10.pth', map_location='cpu')
model.load_state_dict(ckpt['model_state'])
optimizer.load_state_dict(ckpt['optimizer_state'])
start_epoch = ckpt['epoch'] + 1
torch.set_rng_state(ckpt['rng_state'])Save Best Model
Save the best checkpoint by validation metric so you can recover the best model even if later epochs overfit. For most tasks keep only the best checkpoint to save disk; for research, keep per-epoch checkpoints so you can roll back. Combine with a CSV log of (epoch, train_loss, val_loss, val_acc, path) to know which checkpoint to pick.
best_acc = 0.0
for epoch in range(epochs):
train_one_epoch(model, ...)
acc = evaluate(model, val_loader)
if acc > best_acc:
best_acc = acc
torch.save(model.state_dict(), 'best.pth')
print(f"new best {acc:.4f} saved")
# load best at the end
model.load_state_dict(torch.load('best.pth'))
final_acc = evaluate(model, test_loader)Loading Partial Models
strict=False returns lists of missing and unexpected keys — inspect them to confirm only the expected layers were skipped. Renaming keys lets you adapt a checkpoint to a renamed architecture. After loading a backbone, freeze its weights for pure feature extraction or train with a much smaller LR than the new head.
# transfer learning: load a backbone, drop the classifier
pretrained = torch.load('resnet_backbone.pth')
model = ResNet(num_classes=100)
# drop mismatched keys (e.g. final fc)
missing, unexpected = model.load_state_dict(pretrained, strict=False)
print('missing:', missing) # e.g. ['fc.weight', 'fc.bias']
print('unexpected:', unexpected)
# rename keys before loading
pretrained = {k.replace('encoder.', 'backbone.'): v
for k, v in pretrained.items()}
# freeze loaded weights
for name, p in model.named_parameters():
if 'fc' not in name:
p.requires_grad_(False)Export to TorchScript
TorchScript produces a self-contained graph you can load in LibTorch (C++) or Python without the original class — ideal for deployment. trace captures one control-flow path; script handles data-dependent control flow but requires type annotations. ONNX exports for inference in ONNX Runtime, TensorRT, or OpenVINO. Use dynamic_axes for variable batch size.
# scripted (production-ready, runs without Python)
scripted = torch.jit.script(model)
scripted.save('model_scripted.pt')
# traced (records a static graph from sample input)
example = torch.randn(1, 3, 224, 224)
traced = torch.jit.trace(model, example)
traced.save('model_traced.pt')
# load TorchScript in C++ / Python
loaded = torch.jit.load('model_scripted.pt')
loaded.eval()
out = loaded(example)
# export to ONNX (cross-framework)
torch.onnx.export(model, example, 'model.onnx',
input_names=['input'],
output_names=['output'],
dynamic_axes={'input': {0: 'batch'}})CNN
Conv2d Layer
Conv2d weight shape is (out_channels, in_channels/groups, kH, kW). NCHW is the default memory layout. padding='same' (PyTorch 1.9+) keeps spatial size for stride 1. groups=in_channels makes it depthwise (used in MobileNet). Compute output size: (H + 2P - K) / S + 1.
import torch.nn as nn
conv = nn.Conv2d(
in_channels=3, # RGB input
out_channels=16, # number of filters
kernel_size=3, # 3x3 filter
stride=1,
padding=1, # 'same' for kernel 3, stride 1
bias=True,
)
print(conv.weight.shape) # (16, 3, 3, 3)
x = torch.randn(4, 3, 32, 32) # NCHW
out = conv(x)
print(out.shape) # (4, 16, 32, 32) with padding=1
# depthwise separable conv
dw = nn.Conv2d(16, 16, 3, padding=1, groups=16)Pooling Layers
Pooling downsamples spatial dimensions, adding invariance to small translations. MaxPool keeps the strongest response; AvgPool is smoother. AdaptiveAvgPool2d(1) collapses spatial dims to 1x1 — the standard 'global average pool' used to feed conv features into a Linear head. Prefer strided convs over pooling for learnable downsampling in modern architectures.
import torch.nn as nn
x = torch.randn(4, 16, 32, 32)
# max pooling
pool = nn.MaxPool2d(kernel_size=2, stride=2)
out = pool(x) # shape (4, 16, 16, 16)
# average pooling
avg = nn.AvgPool2d(2, stride=2)
# adaptive pool — output size fixed regardless of input
gap = nn.AdaptiveAvgPool2d(1) # global average pool
out = gap(x) # shape (4, 16, 1, 1)
# fractional max pool (slightly improves accuracy)
fmp = nn.FractionalMaxPool2d(2, output_ratio=0.5)CNN Architecture
A canonical CNN block is Conv -> BatchNorm -> ReLU -> Pool. AdaptiveAvgPool2d(1) makes the network input-size agnostic so you can train at one resolution and test at another. BatchNorm2d goes after the conv, before the activation. Keep conv kernels at 3x3 with padding=1 (VGG-style) — they are parameter-efficient and fuse well.
import torch.nn as nn
class TinyCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 32 -> 16
nn.Conv2d(32, 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 16 -> 8
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(64, num_classes),
)
def forward(self, x):
return self.classifier(self.features(x))Feature Maps & Receptive Field
Feature maps show what each channel detects — early layers find edges and colors, deeper layers find textures and parts. register_forward_hook lets you capture activations without modifying forward. Receptive field grows with depth and pooling; roughly, output pixel (i,j) depends on a patch of input centered there with size = sum of strides and kernels up to that layer.
# visualize feature maps
model.eval()
x = images[0:1].to(device)
with torch.no_grad():
feat = model.features[0](x) # first conv output
print(feat.shape) # (1, 32, H, W)
import matplotlib.pyplot as plt
fig, axes = plt.subplots(4, 8, figsize=(12, 6))
for i, ax in enumerate(axes.flat):
if i < feat.shape[1]:
ax.imshow(feat[0, i].cpu(), cmap='viridis')
ax.axis('off')
# hook to capture intermediate activations
acts = {}
def hook(module, inp, out): acts['layer'] = out
model.features[3].register_forward_hook(hook)Batch Normalization
BatchNorm2d normalizes per channel over (N, H, W), keeping running EMA stats for inference. train()/eval() matters — eval uses running stats so output is deterministic. BatchNorm couples examples within a batch, which breaks with very small batches; switch to GroupNorm or LayerNorm then. SyncBatchNorm is needed for correct multi-GPU BN.
import torch.nn as nn
# 2D BN for conv outputs
bn = nn.BatchNorm2d(64)
bn.weight.shape # (64,) — learnable scale gamma
bn.bias.shape # (64,) — learnable shift beta
bn.running_mean.shape # (64,) — EMA of batch means
bn.running_var.shape # (64,) — EMA of batch vars
# 1D BN for linear / time-series
bn1d = nn.BatchNorm1d(128)
# in training mode: use batch stats, update running stats
model.train()
# in eval mode: use running stats (deterministic)
model.eval()
# alternatives
nn.LayerNorm(128) # normalize over features (transformers)
nn.GroupNorm(8, 64) # split channels into 8 groups
nn.InstanceNorm2d(64) # per-sample, style transferPopular CNN Architectures
torchvision.models exposes ResNet, EfficientNet, MobileNet, ViT, and more with pretrained ImageNet weights. Replacing the final fc layer is the standard transfer-learning entry point. For detection/segmentation, drop the head and use the conv features. Check the weights API (WeightsEnum) — the old pretrained=True flag is deprecated. Each model has a known receptive field and FLOPs budget; pick one that fits your latency target.
import torchvision.models as M
# pretrained backbones
resnet = M.resnet50(weights=M.ResNet50_Weights.DEFAULT)
effnet = M.efficientnet_b0(weights=M.EfficientNet_B0_Weights.DEFAULT)
vgg = M.vgg16(weights=M.VGG16_Weights.DEFAULT)
mobnet = M.mobilenet_v3_small(weights=M.MobileNet_V3_Small_Weights.DEFAULT)
# replace classifier head
num_classes = 100
resnet.fc = nn.Linear(resnet.fc.in_features, num_classes)
# feature extractor (drop head)
backbone = nn.Sequential(*list(resnet.children())[:-1]) # outputs (N, 2048, 1, 1)
features = backbone(images).flatten(1) # (N, 2048)RNN & LSTM
RNN Layer
batch_first=True makes the input (batch, seq, feature) which matches most NLP conventions; without it PyTorch uses (seq, batch, feature). Vanilla RNN suffers from vanishing gradients on long sequences — prefer LSTM or GRU in practice. out contains per-timestep hidden states from the last layer; h_n contains the final state of each layer.
import torch.nn as nn
rnn = nn.RNN(
input_size=64,
hidden_size=128,
num_layers=2,
batch_first=True, # input shape (batch, seq, feature)
bidirectional=False,
nonlinearity='tanh',
)
x = torch.randn(32, 10, 64) # (batch, seq, input)
out, h_n = rnn(x)
print(out.shape) # (32, 10, 128) — per-step hidden states
print(h_n.shape) # (2, 32, 128) — final hidden state per layer
# last-step output for classification
last = out[:, -1, :] # shape (32, 128)LSTM Layer
LSTM returns both hidden state h_n and cell state c_n. Bidirectional doubles the output feature size. For classification, use the final hidden state of each direction (h_n[-2] is the last forward layer, h_n[-1] is the last backward layer). dropout in nn.LSTM only applies between stacked layers, not on the input — wrap the input with nn.Dropout if you need that.
import torch.nn as nn
lstm = nn.LSTM(
input_size=64,
hidden_size=128,
num_layers=2,
batch_first=True,
bidirectional=True,
dropout=0.5, # between stacked layers
)
x = torch.randn(32, 10, 64)
out, (h_n, c_n) = lstm(x)
print(out.shape) # (32, 10, 256) — 128 * 2 directions
print(h_n.shape) # (4, 32, 128) — 2 layers * 2 directions
print(c_n.shape) # (4, 32, 128) — cell state
# concatenate final forward + backward for classification
last = torch.cat([h_n[-2], h_n[-1]], dim=1) # shape (32, 256)GRU Layer
GRU has only a hidden state (no cell state) and two gates instead of three, so it has fewer parameters and trains slightly faster than LSTM. Empirically GRU and LSTM perform similarly on most tasks; GRU is a good default for smaller datasets. Like LSTM, dropout only applies between stacked layers.
import torch.nn as nn
gru = nn.GRU(
input_size=64,
hidden_size=128,
num_layers=2,
batch_first=True,
)
x = torch.randn(32, 10, 64)
out, h_n = gru(x) # GRU has no cell state
print(out.shape) # (32, 10, 128)
print(h_n.shape) # (2, 32, 128)
# GRU vs LSTM: fewer parameters, slightly faster,
# often similar performance on many tasksPacked Sequences
Packing skips the padding tokens in the RNN computation, which is faster and avoids biasing the hidden state with pad zeros. Sequences must be sorted by length descending for older PyTorch; enforce_sorted=False sorts internally (with a small overhead). pad_packed_sequence converts back to a padded tensor. Combine with a custom collate_fn that pads batches dynamically.
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
# variable-length sequences in a batch
seqs = [torch.randn(5, 64), torch.randn(3, 64), torch.randn(8, 64)]
lens = torch.tensor([5, 3, 8])
# pad to (batch, max_len, feature)
padded = torch.nn.utils.rnn.pad_sequence(seqs, batch_first=True)
# sort by length descending (required by older versions)
padded = padded[torch.argsort(lens, descending=True)]
lens_sorted = lens[torch.argsort(lens, descending=True)]
packed = pack_padded_sequence(padded, lens_sorted,
batch_first=True, enforce_sorted=True)
out_packed, h_n = lstm(packed)
out, _ = pad_packed_sequence(out_packed, batch_first=True)Sequence Classification
A standard text classifier: embedding -> biLSTM -> dropout -> linear. Use padding_idx in nn.Embedding so the pad token has zero gradient. Pass lengths.cpu() to pack_padded_sequence because it runs on CPU. The final hidden state of both directions is concatenated to capture the whole sequence. For long sequences, transformers outperform LSTMs but need more data and compute.
import torch.nn as nn
class TextClassifier(nn.Module):
def __init__(self, vocab, emb_dim=128, hidden=256, classes=10):
super().__init__()
self.emb = nn.Embedding(vocab, emb_dim, padding_idx=0)
self.lstm = nn.LSTM(emb_dim, hidden, batch_first=True,
bidirectional=True, num_layers=2)
self.dropout = nn.Dropout(0.5)
self.fc = nn.Linear(hidden * 2, classes)
def forward(self, x, lengths):
x = self.emb(x)
packed = pack_padded_sequence(x, lengths.cpu(),
batch_first=True, enforce_sorted=False)
_, (h_n, _) = self.lstm(packed)
last = torch.cat([h_n[-2], h_n[-1]], dim=1)
return self.fc(self.dropout(last))
model = TextClassifier(vocab=10000)Sequence Generation (Decoder)
A decoder runs one step at a time, conditioning on the previous output token. LSTMCell is a single-step LSTM — use it for fine-grained control over the loop. Teacher forcing feeds the ground-truth token at the next step during training, which converges much faster than feeding the model's own predictions. At inference, switch to feeding argmax (or sample) from the previous step's logits.
import torch.nn as nn
class Decoder(nn.Module):
def __init__(self, vocab, emb_dim=128, hidden=256):
super().__init__()
self.emb = nn.Embedding(vocab, emb_dim)
self.lstm = nn.LSTMCell(emb_dim, hidden)
self.fc = nn.Linear(hidden, vocab)
def forward(self, tgt, hidden):
# tgt shape (batch, T)
outputs = []
h, c = hidden
for t in range(tgt.size(1)):
emb = self.emb(tgt[:, t])
h, c = self.lstm(emb, (h, c))
outputs.append(self.fc(h))
return torch.stack(outputs, dim=1) # (batch, T, vocab)
# teacher forcing: feed ground truth next token
# at inference: feed argmax of previous outputTransformer
nn.MultiheadAttention
nn.MultiheadAttention computes scaled dot-product attention over heads in parallel. batch_first matches the NLP convention (batch, seq, dim). Use attn_mask for causal decoding (lower-triangular mask) and key_padding_mask to ignore padding tokens. need_weights=True returns the attention matrix, useful for visualization and probing.
import torch.nn as nn
attn = nn.MultiheadAttention(
embed_dim=512,
num_heads=8,
dropout=0.1,
batch_first=True, # input (batch, seq, dim)
)
q = k = v = torch.randn(32, 10, 512)
out, weights = attn(q, k, v, need_weights=True)
print(out.shape) # (32, 10, 512)
print(weights.shape) # (32, 10, 10) — attention scores
# causal / self-attention mask
mask = nn.Transformer.generate_square_subsequent_mask(10)
out, _ = attn(q, k, v, attn_mask=mask) # masked attention
# key padding mask (ignore pad positions)
key_padding_mask = (tokens == 0) # True = ignore
out, _ = attn(q, k, v, key_padding_mask=key_padding_mask)Transformer Encoder
TransformerEncoderLayer is a self-attention + FFN block with residuals and LayerNorm. norm_first=True (pre-LN) is more stable for training from scratch; post-LN (default) needs a warmup. Stack N layers with nn.TransformerEncoder. Pass src_key_padding_mask so attention ignores pad positions. For classification, mean-pool the output sequence and feed a Linear head.
import torch.nn as nn
enc_layer = nn.TransformerEncoderLayer(
d_model=512,
nhead=8,
dim_feedforward=2048,
dropout=0.1,
activation='relu',
batch_first=True,
norm_first=True, # pre-LN, more stable
)
encoder = nn.TransformerEncoder(enc_layer, num_layers=6)
x = torch.randn(32, 10, 512)
out = encoder(x)
print(out.shape) # (32, 10, 512)
# with padding mask
pad_mask = (tokens == 0)
out = encoder(x, src_key_padding_mask=pad_mask)Positional Encoding
Transformers are permutation-invariant, so they need positional information. The sinusoidal encoding generalizes to longer sequences than seen in training. register_buffer stores pe without making it a parameter — it moves with .to(device) and is saved in state_dict. Learned position embeddings (nn.Parameter) work too but do not extrapolate beyond the training length.
import torch
import torch.nn as nn
import math
class PositionalEncoding(nn.Module):
def __init__(self, d_model=512, max_len=5000, dropout=0.1):
super().__init__()
self.dropout = nn.Dropout(dropout)
pe = torch.zeros(max_len, d_model)
pos = torch.arange(0, max_len).unsqueeze(1).float()
div = torch.exp(torch.arange(0, d_model, 2).float()
* (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(pos * div)
pe[:, 1::2] = torch.cos(pos * div)
self.register_buffer('pe', pe.unsqueeze(0)) # (1, max_len, d_model)
def forward(self, x):
x = x + self.pe[:, :x.size(1)]
return self.dropout(x)
# usage
emb = nn.Embedding(vocab, 512)
pos = PositionalEncoding(512)
x = pos(emb(tokens)) # (batch, seq, 512)Full Transformer (Seq2Seq)
A full encoder-decoder transformer for translation/summarization. The decoder uses causal masking on the target (so position t can only attend to <= t) plus cross-attention to the encoder output (memory). Pass key_padding_masks for both source and target so attention ignores pad tokens. The head projects decoder outputs to vocabulary logits; loss is CrossEntropyLoss with ignore_index=0.
import torch.nn as nn
class Seq2SeqTransformer(nn.Module):
def __init__(self, vocab_src, vocab_tgt, d=512, h=8, layers=6):
super().__init__()
self.src_emb = nn.Embedding(vocab_src, d, padding_idx=0)
self.tgt_emb = nn.Embedding(vocab_tgt, d, padding_idx=0)
self.pos = PositionalEncoding(d)
enc_layer = nn.TransformerEncoderLayer(d, h, d*4, batch_first=True, norm_first=True)
dec_layer = nn.TransformerDecoderLayer(d, h, d*4, batch_first=True, norm_first=True)
self.encoder = nn.TransformerEncoder(enc_layer, layers)
self.decoder = nn.TransformerDecoder(dec_layer, layers)
self.head = nn.Linear(d, vocab_tgt)
def forward(self, src, tgt, src_pad, tgt_pad):
src = self.pos(self.src_emb(src))
tgt = self.pos(self.tgt_emb(tgt))
memory = self.encoder(src, src_key_padding_mask=src_pad)
causal = nn.Transformer.generate_square_subsequent_mask(tgt.size(1))
out = self.decoder(tgt, memory,
tgt_mask=causal,
tgt_key_padding_mask=tgt_pad,
memory_key_padding_mask=src_pad)
return self.head(out)Vision Transformer (ViT) Block
A ViT block is pre-LN self-attention + pre-LN MLP with residuals — essentially the same as a transformer encoder layer. Vision Transformers patchify the image with a strided Conv2d (kernel=stride=patch_size) then treat patches as tokens. Add a CLS token and positional embeddings. nn.MultiheadAttention returns (output, weights) — unpack with need_weights=False to skip the weights.
import torch.nn as nn
class ViTBlock(nn.Module):
def __init__(self, dim=768, heads=12, mlp=3072, dropout=0.1):
super().__init__()
self.norm1 = nn.LayerNorm(dim)
self.attn = nn.MultiheadAttention(dim, heads, dropout=dropout, batch_first=True)
self.norm2 = nn.LayerNorm(dim)
self.mlp = nn.Sequential(
nn.Linear(dim, mlp),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(mlp, dim),
nn.Dropout(dropout),
)
def forward(self, x):
h = self.norm1(x)
a, _ = self.attn(h, h, h, need_weights=False)
x = x + a
x = x + self.mlp(self.norm2(x))
return x
# patchify: (B, 3, 224, 224) -> (B, 196, 768) via Conv2d(3, 768, 16, 16)Attention from Scratch
Scaled dot-product attention: scores = QK^T / sqrt(d_k), softmax, multiply by V. The 1/sqrt(d_k) keeps variance stable so softmax does not saturate. masked_fill with -inf before softmax zeroes out those positions. Multi-head attention splits the embedding into H heads, attends in parallel, and concatenates. This is exactly what nn.MultiheadAttention does — write it from scratch only to learn or to add variants like linear/flash attention.
import torch
import torch.nn.functional as F
import math
def attention(q, k, v, mask=None, dropout=None):
# q, k, v shape (batch, heads, seq, dim)
d_k = q.size(-1)
scores = (q @ k.transpose(-2, -1)) / math.sqrt(d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
attn = F.softmax(scores, dim=-1)
if dropout is not None:
attn = dropout(attn)
out = attn @ v
return out, attn
# multi-head: reshape (batch, seq, dim) -> (batch, heads, seq, dim//heads)
def split_heads(x, heads):
b, s, d = x.shape
return x.view(b, s, heads, d // heads).transpose(1, 2)GPU Training
Device Selection
Always gate on torch.cuda.is_available() so CPU-only machines do not crash. You can create tensors directly on a device with the device= argument (faster than creating on CPU and moving). torch.device context (PyTorch 2.x) sets a default device for new tensors. MPS supports Apple Silicon GPUs but lacks some CUDA features — check operator support before relying on it.
import torch
# pick device
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(device) # cuda or cpu
print(torch.cuda.device_count()) # number of GPUs
print(torch.cuda.get_device_name(0)) # GPU name
# use a specific GPU
torch.cuda.set_device(0)
x = torch.randn(3, device='cuda:1') # directly on GPU 1
# default device context (PyTorch 2.x)
with torch.device('cuda'):
y = torch.randn(4) # on GPU
# Apple Silicon
device = 'mps' if torch.backends.mps.is_available() else 'cpu'Moving Models & Data
model.to(device) moves parameters and buffers in place. Move input tensors inside the training loop so the DataLoader can stream from disk. non_blocking=True overlaps the CPU→GPU copy with compute when the source tensor is in pinned memory (set pin_memory=True on the DataLoader). .cpu() / .numpy() before any NumPy op — GPU tensors cannot be converted directly.
model = MLP(784, 128, 10).to(device)
for x, y in train_loader:
x, y = x.to(device), y.to(device)
# non-blocking copy if pin_memory=True
x = x.to(device, non_blocking=True)
...
# check placement
print(next(model.parameters()).device) # cuda:0
print(x.device)
# move a tensor to CPU for numpy / saving
arr = x.detach().cpu().numpy()
# half precision
model = model.half() # float16
x = x.half()Multiple GPUs (DataParallel)
DataParallel (DP) replicates the model on each GPU, splits the batch, and gathers outputs on GPU 0 — easy but slow and unbalanced (GPU 0 does the gather and loss compute). It is single-process so the GIL limits scaling. For any serious multi-GPU work, use DistributedDataParallel instead. DP is fine for quick prototyping on 2-4 GPUs.
import torch.nn as nn
# simple, single-process data parallelism
model = MLP(784, 512, 10).to(device)
model = nn.DataParallel(model) # wrap AFTER .to(device)
# now model behaves normally
out = model(x)
loss = criterion(out, y)
loss.backward() # gradients are summed across GPUs
# access underlying module
inner = model.module
# specify which GPUs
model = nn.DataParallel(model, device_ids=[0, 1, 2])DistributedDataParallel
DistributedDataParallel (DDP) spawns one process per GPU, each with its own model replica; gradients are all-reduced so every GPU sees the same gradient. It is much faster and more scalable than DataParallel. Use torchrun (or torch.distributed.launch) to start processes. Always call sampler.set_epoch(epoch) so each epoch reshuffles. See the distributed-training section for more.
import os
import torch.distributed as dist
import torch.nn as nn
# launch with: torchrun --nproc_per_node=4 train.py
dist.init_process_group(backend='nccl')
local_rank = int(os.environ['LOCAL_RANK'])
torch.cuda.set_device(local_rank)
model = MLP(784, 512, 10).to(local_rank)
model = nn.parallel.DistributedDataParallel(
model, device_ids=[local_rank], output_device=local_rank,
)
sampler = torch.utils.data.distributed.DistributedSampler(dataset)
loader = DataLoader(dataset, batch_size=64, sampler=sampler)
for epoch in range(epochs):
sampler.set_epoch(epoch) # shuffle differently each epoch
for x, y in loader:
x, y = x.to(local_rank), y.to(local_rank)
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
optimizer.zero_grad()Memory Management
empty_cache() returns cached memory to the OS but does not free tensors still in use — call it after a big spike, not in the hot loop. memory_allocated is tensors in use; memory_reserved is the caching allocator's pool. To reduce OOM: lower batch size, use mixed precision, gradient checkpointing, or gradient accumulation. expandable_segments:True reduces fragmentation for variable-shape workloads. Avoid retaining references to tensors you no longer need (e.g. logged losses in a list).
import torch
# release unused memory
torch.cuda.empty_cache()
# current memory usage
print(torch.cuda.memory_allocated() / 1e9, 'GB allocated')
print(torch.cuda.memory_reserved() / 1e9, 'GB reserved')
# per-tensor memory
x = torch.randn(1000, 1000, device='cuda')
print(x.element_size() * x.nelement() / 1e6, 'MB')
# peak memory tracking
torch.cuda.reset_peak_memory_stats()
# ... run training ...
print(torch.cuda.max_memory_allocated() / 1e9, 'GB peak')
# out-of-memory debugging
import os
os.environ['PYTORCH_CUDA_ALLOC_CONF'] = 'expandable_segments:True'CuDNN & Performance
cudnn.benchmark=True lets cuDNN profile algorithms on the first forward and cache the fastest — great when input shapes are fixed (typical CNN), harmful if shapes change every step (RNNs with variable length). TF32 uses 19-bit mantissa on Ampere+ for ~3x matmul speedup with minimal accuracy loss; enable it unless you need strict fp32 reproducibility. Deterministic mode disables non-deterministic algorithms.
import torch.backends.cudnn as cudnn
# benchmark finds the fastest conv algorithm (good for fixed input sizes)
cudnn.benchmark = True
# deterministic mode (slower, reproducible)
cudnn.deterministic = True
cudnn.benchmark = False
# allow TF32 on Ampere+ (big speedup, slight precision loss)
torch.backends.cuda.matmul.allow_tf32 = True
cudnn.allow_tf32 = True
# disable cudnn (debugging)
cudnn.enabled = FalseTransforms & Preprocessing
Common Transforms
ToTensor converts a PIL image (0-255) to a float tensor in [0,1] and rearranges HWC -> CHW. Normalize then standardizes per channel using ImageNet statistics (the de-facto standard for pretrained models — always match the stats the backbone was trained with). Apply transforms in the Dataset so they run on worker processes; the GPU never waits on CPU preprocessing.
from torchvision import transforms
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(), # PIL -> Tensor, scales to [0,1]
transforms.Normalize( # per-channel standardize
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225],
),
])
# apply to a PIL image
img = transform(pil_image)
print(img.shape) # (3, 224, 224)
print(img.min(), img.max()) # standardized
dataset = datasets.ImageFolder('data/', transform=transform)Data Augmentation
Augmentation regularizes the model by showing it varied inputs. RandomResizedCrop + flip is the classic ImageNet augmentation. ColorJitter changes color without affecting geometry. TrivialAugmentWide and RandAugment sample random ops with random magnitudes — strong, simple, and surprisingly effective. Use a separate non-augmented transform for validation so metrics are stable.
from torchvision import transforms
train_tf = transforms.Compose([
transforms.RandomResizedCrop(224),
transforms.RandomHorizontalFlip(),
transforms.ColorJitter(brightness=0.2, contrast=0.2,
saturation=0.2, hue=0.1),
transforms.RandomRotation(15),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
# stronger augmentation: RandAugment / TrivialAugment
train_tf = transforms.Compose([
transforms.TrivialAugmentWide(),
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])Transforms v2 (New API)
Transforms v2 (torchvision 0.17+) is the new API — faster, JIT-scriptable, and crucially works jointly on image/mask/bbox so detection and segmentation augmentations stay consistent. ToDtype + scale=True replaces ToTensor. Always pass antialias=True to resize ops for cleaner downsample. The legacy transforms module still works but won't get new features.
from torchvision.transforms import v2
transform = v2.Compose([
v2.RandomResizedCrop(224, antialias=True),
v2.RandomHorizontalFlip(),
v2.ToDtype(torch.float32, scale=True), # scale to [0,1]
v2.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
# v2 works on images, videos, masks, and bounding boxes together
imgs, masks = transform(imgs, masks)
imgs, bboxes, labels = transform(imgs, bboxes, labels)
# supports more ops and JIT scripting
transform = v2.RandomChoice([
v2.RandomHorizontalFlip(),
v2.RandomVerticalFlip(),
])Custom Transform
A custom transform is any callable that takes a tensor (or PIL image) and returns one. Compose them with transforms.Compose. Apply noise after ToTensor so it operates on floats. For per-sample randomness use torch.rand inside __call__ so each call gets a fresh decision. Transforms v2 lets you write transform classes that handle joint (image, label) inputs by overriding forward.
import torch
from torchvision import transforms
class RandomGaussianNoise:
def __init__(self, std=0.1):
self.std = std
def __call__(self, x):
if self.std > 0:
return x + torch.randn_like(x) * self.std
return x
# compose with built-ins
transform = transforms.Compose([
transforms.ToTensor(),
RandomGaussianNoise(0.05),
transforms.Normalize([0.5], [0.5]),
])
# callable with probability
class RandomApply:
def __init__(self, fn, p=0.5):
self.fn, self.p = fn, p
def __call__(self, x):
return self.fn(x) if torch.rand(1) < self.p else xText & Tabular Preprocessing
For text, build a vocabulary from the training set (never val/test) and convert tokens to integer ids; <unk> handles unseen words and <pad> is used for batching variable-length sequences. For tabular data, standardize features using statistics computed on the training set only — leaking val/test statistics inflates metrics. Save the scaler/vocab alongside the model checkpoint so inference preprocesses identically.
import torch
from torchtext.vocab import build_vocab_from_iterator
# tokenize and numericalize text
tokens = ['hello', 'world', 'this', 'is', 'nlp']
vocab = build_vocab_from_iterator([tokens], specials=['<unk>', '<pad>'])
vocab.set_default_index(vocab['<unk>'])
# text -> token ids
ids = vocab(['hello', 'world', 'unknownword'])
print(ids) # e.g. [3, 4, 0]
# tabular: standardize features
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler().fit(X_train)
X_train_t = torch.tensor(scaler.transform(X_train), dtype=torch.float32)
X_test_t = torch.tensor(scaler.transform(X_test), dtype=torch.float32)
# fit scaler ONLY on training data, apply to val/testCustom Dataset
Dataset Class
A Dataset just needs __len__ and __getitem__. Open files lazily inside __getitem__ so you can hold millions of paths in memory cheaply. Return (input, target) tuples and let the DataLoader's default collate stack them into batches. Keep __getitem__ cheap and side-effect-free because it runs on worker processes — do not mutate shared state.
from torch.utils.data import Dataset
class ImageDataset(Dataset):
def __init__(self, paths, labels, transform=None):
self.paths = paths
self.labels = labels
self.transform = transform
def __len__(self):
return len(self.paths)
def __getitem__(self, idx):
from PIL import Image
img = Image.open(self.paths[idx]).convert('RGB')
label = self.labels[idx]
if self.transform:
img = self.transform(img)
return img, label
dataset = ImageDataset(paths, labels, transform=train_tf)
img, label = dataset[0]Image Folder Dataset
ImageFolder expects one subdirectory per class and infers labels from folder names — the fastest way to start with image classification. class_to_idx maps names to integer labels; keep it consistent across train/val/test by reusing the same mapping. random_split returns Subsets that share the underlying data, so memory is not duplicated.
from torchvision import datasets
# convention: data/train/cat/*.jpg, data/train/dog/*.jpg
dataset = datasets.ImageFolder('data/train', transform=train_tf)
print(dataset.class_to_idx) # {'cat': 0, 'dog': 1}
# train / val split
from torch.utils.data import random_split
train, val = random_split(dataset, [0.8, 0.2])
# custom split with same class mapping
val_dataset = datasets.ImageFolder('data/val', transform=val_tf)
val_dataset.class_to_idx = dataset.class_to_idx # keep consistent
# use with DataLoader
loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)Text Dataset
Pre-tokenize text once and store integer ids to avoid re-tokenizing every epoch. For variable-length sequences, return raw lists and pad in collate_fn so each batch pads only to its own max length. pack_padded_sequence then skips pad positions in the RNN. For transformer training, return attention masks so padding tokens are ignored by the attention.
from torch.utils.data import Dataset
import torch
class TextDataset(Dataset):
def __init__(self, texts, labels, vocab, max_len=128):
self.texts = texts
self.labels = labels
self.vocab = vocab
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
tokens = self.texts[idx].split()[:self.max_len]
ids = [self.vocab[t] for t in tokens]
ids += [0] * (self.max_len - len(ids)) # pad
return torch.tensor(ids), self.labels[idx]
# variable length: use custom collate
def text_collate(batch):
ids, labels = zip(*batch)
lens = torch.tensor([len(x) for x in ids])
padded = torch.nn.utils.rnn.pad_sequence(ids, batch_first=True)
return padded, lens, torch.tensor(labels)Iterable Dataset
IterableDataset streams data instead of random access — essential for huge or streaming sources (logs, databases, S3). Workers must explicitly split the stream (itertools.islice) to avoid every worker reading the same data. shuffle is not supported directly; instead, buffer samples and shuffle within the buffer. Use this for datasets too large to index by length.
from torch.utils.data import IterableDataset, DataLoader
import itertools
class StreamDataset(IterableDataset):
def __init__(self, file_path):
self.file_path = file_path
def __iter__(self):
# split work across workers
worker_info = torch.utils.data.get_worker_info()
with open(self.file_path) as f:
if worker_info is None:
lines = f
else:
lines = itertools.islice(f, worker_info.id,
None, worker_info.num_workers)
for line in lines:
yield self._parse(line)
def _parse(self, line):
# parse line into tensor + label
return torch.tensor([...]), label
loader = DataLoader(StreamDataset('big.csv'), batch_size=32)TensorDataset & ConcatDataset
TensorDataset is the fastest path when data fits in RAM — it returns tuples of tensors that the default collate stacks into batches. ConcatDataset chains datasets end-to-end; useful for combining labeled and pseudo-labeled data. Subset wraps a dataset with an index list, enabling random_split and custom slicing without copying data.