Data pipelines and augmentation with torchvision
Transforms and Compose, custom Datasets, weighted samplers for imbalanced classes, collate functions, and worker settings that do not stall.
A custom Dataset and the transform split
import torch
from torch.utils.data import Dataset
from torchvision import transforms
from PIL import Image
class ImageDataset(Dataset):
def __init__(self, rows, transform=None):
self.rows = rows # list of (path, label)
self.transform = transform
def __len__(self):
return len(self.rows)
def __getitem__(self, index):
path, label = self.rows[index]
image = Image.open(path).convert("RGB")
if self.transform is not None:
image = self.transform(image)
return image, label
# augmentation only on the training split
train_tf = transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.7, 1.0)),
transforms.RandomHorizontalFlip(),
transforms.ColorJitter(0.2, 0.2, 0.2),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
eval_tf = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])__getitem__must be independent per index. Any shared mutable state across calls breaks with multiple workers, because each worker is a separate process.- Forward slashes throughout. On Windows, a path exactly 260 characters long fails inside a worker with a bare
FileNotFoundError; the fix is a shorter root or the long-path manifest. - The normalisation constants above are the ImageNet statistics. Reusing them for a very different domain (medical, satellite) is a reasonable default but not an optimal one.
ToTensor()converts an HWCuint8image to a CHW float tensor scaled to[0, 1]. Anything after it operates on tensors, not PIL images.
Samplers, collate and DataLoader
from torch.utils.data import DataLoader, WeightedRandomSampler
import numpy as np
labels = np.array([row[1] for row in train_rows])
class_counts = np.bincount(labels)
class_weights = 1.0 / np.maximum(class_counts, 1)
sample_weights = class_weights[labels] # one weight per example
sampler = WeightedRandomSampler(
weights=torch.as_tensor(sample_weights, dtype=torch.double),
num_samples=len(sample_weights),
replacement=True,
)
train_loader = DataLoader(
ImageDataset(train_rows, train_tf),
batch_size=32,
sampler=sampler, # mutually exclusive with shuffle=True
num_workers=4,
pin_memory=True, # faster host-to-device copies on CUDA
persistent_workers=True, # do not restart workers each epoch
drop_last=True,
prefetch_factor=2,
)
# a collate function for variable-length sequences
def pad_collate(batch):
sequences, labels = zip(*batch)
lengths = torch.tensor([len(s) for s in sequences])
padded = torch.nn.utils.rnn.pad_sequence(sequences, batch_first=True)
mask = torch.arange(padded.size(1))[None, :] < lengths[:, None]
return padded, lengths, mask, torch.stack(labels)| Setting | Effect | Guidance |
|---|---|---|
num_workers=0 | Loading in the main process | Blocks the GPU; only for debugging |
num_workers=4-8 | Parallel decode | Start at the CPU core count divided by 2 |
pin_memory=True | Page-locked host buffers | Enable on CUDA, pointless on CPU |
persistent_workers=True | Workers survive epochs | Avoids repeated startup cost |
WeightedRandomSampler | Balanced class sampling | The cheapest imbalance fix |
drop_last=True | Drops a short final batch | Needed for batch norm with tiny batches |
A WeightedRandomSampler balances what the model sees without touching the data, but it also changes the effective prior. Combine it with a class-weighted loss only if you have checked that the two effects do not overshoot.
Making the loader fast
import time
def benchmark(loader, n=30):
it = iter(loader)
next(it) # warm up workers
start = time.perf_counter()
for _ in range(n):
batch = next(it)
elapsed = time.perf_counter() - start
return elapsed / n, batch[0].shape
per_batch, shape = benchmark(train_loader)
print(f"{per_batch * 1000:.1f} ms per batch, last batch {tuple(shape)}")
# worker crash tips: a traceback from inside a worker is truncated to one frame.
# Set the environment before importing torch to get the real error.
import os
os.environ["PYTHONFAULTHANDLER"] = "1"
os.environ["CUDA_LAUNCH_BLOCKING"] = "1" # forces synchronous CUDA errors- Compare batch time against model step time. If loading dominates, add workers; if not, look at the model or at host-to-device transfer.
- Pin a thread count when using many workers:
torch.set_num_threads(1)inside workers avoids thread oversubscription and often doubles throughput. - A worker may only be slow to fault. Errors inside a worker surface with a truncated traceback, so run once with
num_workers=0to get the real exception. persistent_workers=Trueavoids paying process startup every epoch, but it holds memory: on a machine running several jobs, that matters.
⚠️
Reproducibility and workers interact badly. A seeded
random or NumPy generator is duplicated into each worker, so two workers may produce identical augmentation sequences. Seed inside the dataset with the worker id (torch.utils.data.get_worker_info()) if you need varied but reproducible augmentation.FAQ
How many workers should I use?
Start with four. Watch the batch time as you raise it; it falls until the CPU is saturated, then flattens and eventually gets worse from contention. Beware of multiplying workers by CUDA streams: workers are CPU processes, not GPU ones.
Why does my loss not change with a weighted sampler?
The weights are sampled every epoch, so shuffle semantics change and the class distribution in each batch shifts. That is intended. If the loss is identical across steps, check that the sampler actually replaced
shuffle rather than being ignored.Related
Modules, data and saving Mixed precision and GPU performance
Last refreshed 2026-09-18.