Bundled datasets make PyTorch look easy in a way that does not survive contact with your own work. One line downloads sixty thousand perfectly labelled images, already the right size, already the right format, and you get to skip an entire category of work without noticing it exists. Then a real project arrives. The images are in a folder someone shared with you, some are the wrong size, a few have a transparency layer, and the labels live in the folder names or a spreadsheet. That gap is where most people learning PyTorch quietly stall. This tutorial closes it by building the class that sits between whatever data you actually have and the tidy batches a model expects.
Before you begin
This is part four of a five part series on PyTorch foundations. The first three parts used a dataset that came prepackaged. This one is about the moment that stops being true, when the images are yours and sitting in a folder on your own disk.
Nothing here needs downloading. You generate your own set of 450 labelled images in about twenty lines, then build the class that feeds them to a model. Every output came from PyTorch 2.13.0.
Highlights
- A Dataset is anything that can report how many samples exist and hand over sample number N, a deliberately small contract so that almost any data source can satisfy it.
- Bundled datasets skip an entire category of engineering work, and that skipped work is exactly what stalls most people on their first project using their own data.
- Transforms run every time a sample is read rather than once when the dataset is built, which is precisely what makes random augmentation increase your effective dataset size.
- Applying random augmentation to a validation set makes the score move between runs on identical weights, which destroys your ability to tell whether a change actually helped.
- Sorting the class list is a correctness requirement rather than tidiness, because filesystem order is not stable across machines and label indices must still match when a saved model is loaded.
- Indexing file paths instead of loading images in the constructor is what allows a dataset larger than available memory to work at all.
- A DataLoader stacks samples into a single tensor, so one image a pixel taller than the rest fails the entire batch, often several minutes into a training run.
What a Dataset Class Is For
PyTorch needs to ask two questions about your data: how many samples are there, and what is sample number 500? Anything that can answer both can be trained on. The Dataset class is simply the shape those two answers take.
That is a deliberately small contract, and the smallness is the point. Because PyTorch asks so little, almost anything can satisfy it. Images in folders, rows in a CSV, audio clips, records in a database, lines in a log file. You write a short class per data source, and everything downstream stays the same.
The short version
A Dataset is any object that can report how many samples exist and hand over sample number N. Write that once for your data source and the rest of PyTorch works unchanged.
Why Bundled Datasets Hide This Work
The first three parts of this series used MNIST, which arrives through a single function call already labelled, already uniformly sized, already converted to tensors. That convenience quietly skips an entire category of engineering, and the skipped work is exactly what stalls people on their first real project.
Where Dataset Ends and DataLoader Begins
These two are constantly confused, and the split is worth stating plainly because it determines which one you need to modify when something is wrong.
A Dataset deals with one sample at a time. It knows where your files are, how to open one, and what label belongs to it. It has no concept of batches, shuffling, or parallelism.
The DataLoader deals with delivery. It decides which samples to fetch, groups them into batches, stacks them into a single tensor, and can load them in parallel background processes. It knows nothing about what your data means.
Did you know
The practical consequence: if a single sample is wrong, look at your Dataset. If a batch fails to assemble or training is slow while the model sits idle, look at your DataLoader. Errors about stacking tensors of unequal size are the boundary case, since the DataLoader reports them but the Dataset caused them.
Why Transforms Live Inside the Sample Lookup
One design detail explains a lot of later behaviour. Transforms do not run when the dataset is created. They run every time a sample is fetched.
That sounds like wasted effort, and for fixed operations like resizing it partly is. But it is precisely what makes random augmentation possible. If a rotation is applied when a sample is read, then reading the same file in two different epochs produces two different images, and the model effectively trains on a larger dataset than you actually own.
It also explains the most common quiet mistake in this area, which is applying random augmentation to a validation set. Doing so makes your score change between runs on identical weights, so you lose the ability to tell whether a change helped.
Before you start
- Install PyTorch and torchvision. That single command also brings Pillow and NumPy, which this tutorial needs. Outputs below are from PyTorch 2.13.0 and torchvision 0.28.0.
- Understand what a batch is. Part one of this series covers batching with a bundled dataset.
- Know how to write a Python class with methods. Nothing more advanced than that is used here.
- Allow about 20 minutes. Generating the sample dataset takes a few seconds.
Run it in your browser
You do not have to install anything on your own machine. The JupyterLab playground gives you a browser based notebook environment running on a Linux host, so you can open a terminal inside it, install PyTorch with a single pip command, and work through every step of this tutorial in notebook cells. Sessions last one hour and extend by 15 minutes, which is comfortably longer than this tutorial needs. Install with %pip install --upgrade pip first, then %pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu, which also brings Pillow and NumPy. The percent sign matters because a notebook cell runs Python rather than shell, and the playground image ships a pip old enough to fail without the upgrade.
Step 1: Generate a Dataset You Control
Rather than downloading anything, create 450 labelled images of coloured shapes. Reproducible sample data means you can verify your pipeline before pointing it at data that matters.
import os, random
from PIL import Image, ImageDraw
random.seed(7)
ROOT, CLASSES = "shapes", ["circle", "square", "triangle"]
for split, n in {"train": 120, "val": 30}.items():
for cls in CLASSES:
os.makedirs(f"{ROOT}/{split}/{cls}", exist_ok=True)
for i in range(n):
img = Image.new("RGB", (128, 128), (random.randint(230, 255),) * 3)
d = ImageDraw.Draw(img)
colour = (random.randint(0, 180), random.randint(0, 180), random.randint(0, 180))
m = random.randint(15, 30)
box = [m, m, 128 - m, 128 - m]
if cls == "circle":
d.ellipse(box, fill=colour)
elif cls == "square":
d.rectangle(box, fill=colour)
else:
d.polygon([(64, m), (128 - m, 128 - m), (m, 128 - m)], fill=colour)
img.save(f"{ROOT}/{split}/{cls}/{cls}_{i:03d}.png")
print("Generated", sum(len(f) for _, _, f in os.walk(ROOT)), "images")Generated 450 images
train/circle: 120 images
train/square: 120 images
train/triangle: 120 images
val/circle: 30 images
val/square: 30 images
val/triangle: 30 images
What this code does
random.seed(7)
Fixes the randomness so you get the same 450 images every time you run this, which makes your output match the numbers printed here.
Image.new and ImageDraw
Creates a blank square and draws a shape onto it. The colour, size, and background brightness all vary slightly so the model has something real to learn rather than three identical pictures.
os.makedirs(..., exist_ok=True)
Creates the folder structure and does not complain if it already exists, so you can safely run the script twice.
Why generate rather than download
Nothing here depends on an external host staying online or a dataset licence you have to check, and you can verify your pipeline works before pointing it at data that matters.
The resulting layout is the standard convention, where the folder name is the label:
shapes/
|-- train/
| |-- circle/ 120 images
| |-- square/ 120 images
| `-- triangle/ 120 images
`-- val/
|-- circle/ 30 images
|-- square/ 30 images
`-- triangle/ 30 imagesStep 2: Write the Dataset Class
Three methods. That is the whole interface.
import torch
from pathlib import Path
from PIL import Image
from torch.utils.data import Dataset, DataLoader
class ShapeDataset(Dataset):
def __init__(self, root, transform=None):
self.root = Path(root)
self.transform = transform
self.classes = sorted([d.name for d in self.root.iterdir() if d.is_dir()])
self.class_to_idx = {c: i for i, c in enumerate(self.classes)}
self.samples = []
for cls in self.classes:
for path in sorted((self.root / cls).glob("*.png")):
self.samples.append((path, self.class_to_idx[cls]))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
path, label = self.samples[idx]
image = Image.open(path).convert("RGB")
if self.transform:
image = self.transform(image)
return image, labelraw = ShapeDataset("shapes/train")
print("Classes :", raw.classes)
print("class_to_idx :", raw.class_to_idx)
print("Length :", len(raw))
img, label = raw[0]
print("Sample type :", type(img).__name__)
print("Sample label :", label)Classes : ['circle', 'square', 'triangle']
class_to_idx : {'circle': 0, 'square': 1, 'triangle': 2}
Length : 360
Sample type : Image
Sample label : 0
What this code does
__init__
Runs once when you create the dataset. It walks the folders and builds a list of file paths paired with labels. Notice it does not open a single image.
__len__
Reports how many samples exist. DataLoader calls this to work out how many batches an epoch contains.
__getitem__
Fetches one sample by number. This is where the file finally gets opened, the transform runs, and the image plus its label come back.
sorted() used twice
Filesystem order is not guaranteed to be stable across machines. Sorting both the class list and the file list means circle maps to 0 everywhere, which matters enormously when you load a saved model later.
.convert("RGB")
Forces three colour channels. Some PNG files carry a transparency layer and some images are greyscale, and without this you get a mix of three and four channel tensors that fails partway through an epoch.
Sample type is Image, not Tensor
With no transform supplied, you get a raw PIL image back. A DataLoader cannot batch those, which is why every pipeline needs at least ToTensor().
Four design decisions in that class are worth copying into your own work.
__init__ indexes paths, it does not load images. Building a list of file paths is cheap. Loading 360 images into memory is not, and for a dataset of any real size it is impossible. Files are opened only when a sample is actually requested.
sorted() appears twice, deliberately. Filesystem iteration order is not guaranteed to be stable across machines or runs. Sorting both the class list and the file list means circle maps to 0 on your laptop and on the training server, which matters enormously when you load a saved model and interpret its outputs.
.convert("RGB") normalises the channel count. PNG files may carry an alpha channel and some images are greyscale. Without this line you get a mix of three and four channel tensors, and batching fails partway through an epoch rather than immediately.
transform is stored, not applied. The transform runs inside __getitem__, which is what makes random augmentation work correctly. The next steps show why.
Did you know
Without a transform, __getitem__ returns a PIL Image object, not a tensor. A DataLoader cannot batch PIL images, so at minimum every pipeline needs transforms.ToTensor().
Step 3: Add Transforms
A transform pipeline is a list of operations applied in order to each sample.
from torchvision import transforms
tfm = transforms.Compose([
transforms.Resize((64, 64)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
ds = ShapeDataset("shapes/train", transform=tfm)
img, label = ds[0]
print("Tensor shape :", img.shape)
print("Tensor dtype :", img.dtype)
print("Value range :", f"{img.min():.3f} to {img.max():.3f}")Tensor shape : torch.Size([3, 64, 64])
Tensor dtype : torch.float32
Value range : -1.467 to 2.379
What this code does
transforms.Compose
Chains several operations into one pipeline that runs in the order you list them.
Resize((64, 64))
Forces every image to the same dimensions. This is what lets them stack into a batch later, and leaving it out causes one of the most common beginner errors.
ToTensor()
Converts the PIL image into a tensor, rescales values to 0.0 through 1.0, and reorders the dimensions so the colour channel comes first.
Normalize(mean, std)
Subtracts a mean and divides by a standard deviation for each colour channel, which centres the values around zero.
Why the range is negative to positive
That is Normalize doing its job. Values below the mean become negative, which is expected and correct, not a bug.
Order matters and is not arbitrary.
The value range of negative 1.467 to 2.379 surprises people who expected 0 to 1. That is Normalize doing its job: it subtracts a mean and divides by a standard deviation, which centres values around zero and lets some fall below it. Those particular numbers are the channel statistics of ImageNet, and you use them whenever your model was pretrained on ImageNet, which is the subject of part five in this series.
Step 4: Wrap It in a DataLoader
The Dataset returns one sample. The DataLoader turns that into batches.
loader = DataLoader(ds, batch_size=32, shuffle=True, num_workers=0)
print("Batches per epoch:", len(loader))
batch_x, batch_y = next(iter(loader))
print("Batch images :", batch_x.shape)
print("Batch labels :", batch_y.shape)
print("Labels in batch :", batch_y[:12].tolist())Batches per epoch: 12
Batch images : torch.Size([32, 3, 64, 64])
Batch labels : torch.Size([32])
Labels in batch : [1, 1, 0, 2, 2, 2, 1, 0, 0, 0, 0, 1]360 samples in batches of 32 gives 12 batches, the last holding only 8 samples. Pass drop_last=True if a partial final batch causes problems, which it occasionally does with batch normalisation on very small remainders.
The shuffled labels confirm the loader is mixing classes rather than serving all circles, then all squares. That matters: an unshuffled loader would give the model 120 consecutive circles, and it would learn to predict circle for everything before ever seeing a square.
Verify the loader covers the full dataset exactly once per epoch:
counts = torch.zeros(len(ds.classes))
for _, ys in loader:
counts += torch.bincount(ys, minlength=len(ds.classes))
for c, n in zip(ds.classes, counts.tolist()):
print(f" {c}: {int(n)}") circle: 120
square: 120
triangle: 120
What this code does
len(loader)
The number of batches per epoch. 360 samples in groups of 32 gives 12 batches, with the last one holding only 8.
The mixed labels in one batch
Confirms shuffling is working. Without it the model would see 120 circles in a row and learn to answer circle for everything before ever meeting a square.
torch.bincount
Counts how many times each label appeared. Every class shows exactly 120, which proves shuffling changes order without changing membership.
drop_last=True
An option worth knowing. It discards the partial final batch, which occasionally matters when a layer type behaves oddly on very small remainders.
Every sample appears once. Shuffling changes order, never membership.
Step 5: Separate Training and Validation Pipelines
This is the decision that most often causes trouble later, and it is easy to get right once you see it stated plainly.
train_tfm = transforms.Compose([
transforms.Resize((64, 64)),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomRotation(15),
transforms.ToTensor(),
])
val_tfm = transforms.Compose([
transforms.Resize((64, 64)),
transforms.ToTensor(),
])
train_ds = ShapeDataset("shapes/train", transform=train_tfm)
val_ds = ShapeDataset("shapes/val", transform=val_tfm)
print("train samples:", len(train_ds), "| val samples:", len(val_ds))train samples: 360 | val samples: 90Now confirm that the random transforms really do produce a different tensor each time the same index is read:
torch.manual_seed(1); first = train_ds[0][0]
torch.manual_seed(2); second = train_ds[0][0]
print("Augmentation on, same index, identical tensors? ", torch.equal(first, second))
print("Augmentation off, same index, identical tensors?", torch.equal(val_ds[0][0], val_ds[0][0]))Augmentation on, same index, identical tensors? False
Augmentation off, same index, identical tensors? True
What this code does
RandomHorizontalFlip and RandomRotation
Alter the image slightly and differently on each read. This is the augmentation, and it belongs only on training data.
Why the training tensors differ
Index 0 points at the same file both times, but the transform re rolls its randomness on every access, so you get two different versions of one image.
Why validation must stay deterministic
If your validation transform randomised, your accuracy score would shift between runs on identical weights, and you could never tell whether a change helped or you simply got luckier rotations.
Choosing safe augmentations
A horizontal flip is fine for shapes and animals. It is actively harmful for handwritten digits, where flipping can teach the model that mirrored digits share a class.
Index 0 points at the same file both times, yet the training pipeline returns different tensors because the flip and rotation are re rolled on each access. That is the mechanism behind augmentation: the model effectively sees a larger, more varied dataset without you storing a single extra file.
The validation pipeline is deterministic, and it has to be. If your validation transform randomised, your accuracy score would change between runs on identical weights, and you would have no way to tell whether a change you made helped or whether you simply got a luckier set of rotations.
Did you know
Choose augmentations that preserve the label. A horizontal flip is safe for shapes and animals. It is actively harmful for handwritten digits, where flipping can turn a 2 into something that resembles a 5, teaching the model that mirrored digits share a class.
Step 6: Know When ImageFolder Is Enough
When your data already sits in class named folders, torchvision does this for you.
from torchvision import datasets
ds = datasets.ImageFolder("shapes/train", transform=tfm)
print(ds.classes, len(ds))
Writing the class once is worth it precisely because the first row describes tutorials and the other four describe real projects.
Break It On Purpose
Break it on purpose
Remove transforms.Resize((64, 64)) from your pipeline, then return images of differing sizes from __getitem__ and iterate the loader.
RuntimeError: stack expects each tensor to be equal size, but got [3, 32, 32] at entry 0 and [3, 33, 32] at entry 1
Why it happens: the DataLoader combines individual samples into one batched tensor using torch.stack, which requires every tensor to have identical dimensions. A single image one pixel taller than the rest breaks the whole batch.
This error is common with real photo datasets because cameras produce varied resolutions and orientations. The fix is always the same: put a Resize or CenterCrop in the pipeline so every sample leaves __getitem__ the same shape. Note also that this failure surfaces at the first batch containing a mismatch, not at dataset creation, so it can appear several minutes into a training run.
Error Decoder
Performance Notes Worth Knowing Early
num_workers controls how many subprocesses load data in parallel. The default of 0 loads on the main process, which means the GPU sits idle while images are read and decoded. Setting it to 4 is a reasonable starting point on most machines. Note that on Windows and in Jupyter notebooks, values above 0 sometimes require guarding your entry point with if __name__ == "__main__":.
pin_memory=True speeds up the transfer from CPU to GPU and is worth setting whenever you train on a GPU. It has no benefit on CPU only training.
Keep __getitem__ light. It runs once per sample per epoch, so a dataset of 100,000 images calls it 100,000 times every epoch. Expensive parsing, network requests, or database queries inside that method will dominate your training time. Where possible, do heavy preprocessing once and cache the result to disk.
Conclusion
The Dataset class is small on purpose. Index your samples in __init__, report the count in __len__, load and return one sample in __getitem__, and the rest of PyTorch works with whatever data you have.
Four things are worth carrying forward. Index paths rather than loading files up front, or memory becomes the constraint. Sort your class list so label indices stay identical across machines. Put every random augmentation on the training pipeline and nothing random on validation. Resize inside the pipeline so every sample stacks cleanly into a batch.
Once this class exists, the model code from part one runs unchanged against your own images. That is the whole point of the abstraction, and it is why writing it once pays back across every project that follows.
FAQS
Q1: When should I write a custom Dataset instead of using ImageFolder?
Use ImageFolder when your images already sit in subfolders named after their classes and each image has exactly one label, because it does that job well and saves you writing code. Write your own Dataset in every other situation, which turns out to be most real projects. Common triggers are labels stored in a CSV or database rather than encoded in folder names, images that need pairing with something else such as a segmentation mask or a second view, multiple labels per sample, regression targets instead of classes, and data that streams from object storage rather than living on local disk. The class is short enough that writing it is rarely the bottleneck, and having written one means you are never blocked by a dataset layout that does not match a convention.
Q2: Why do transforms go inside getitem rather than being applied once upfront?
Because random augmentations need to produce different results each time a sample is read, and applying them once would defeat their entire purpose. When __getitem__ runs the transform on every access, requesting index 0 in epoch one and epoch two yields two differently rotated and flipped versions of the same file, so the model sees far more variety than your file count suggests. The tutorial demonstrates this directly: with augmentation enabled, reading the same index twice returns tensors that are not equal, while the deterministic validation pipeline returns identical tensors. There is a real cost, since transforms run on every access rather than once, which is why heavier preprocessing steps that are not random are often better cached to disk ahead of time.
Q3: What does num_workers actually do and what value should I use?
num_workers sets how many separate subprocesses load and preprocess data in parallel while your model trains. At the default of 0, loading happens on the main process, so the GPU waits idle while images are read from disk, decoded, and transformed. Setting it to 4 is a sensible starting point on most machines, and raising it further helps until you saturate disk throughput or run short of RAM, since each worker holds its own copy of the dataset object. Watch for two platform quirks: on Windows and inside Jupyter notebooks, values above 0 sometimes require your training code to sit inside an if __name__ == "__main__": guard, and very high worker counts can slow things down through process startup overhead on small datasets. If your GPU utilisation sits low while CPU sits high, data loading is your bottleneck and this is the first knob to turn.
Q4: How do I handle a dataset where one class has far more samples than the others?
Class imbalance skews training toward whichever class dominates, because predicting the majority class becomes an easy way to reduce loss. Three approaches address it and they combine well. Use WeightedRandomSampler in your DataLoader to oversample minority classes so batches stay balanced, which changes what the model sees without changing your files. Pass class weights into your loss function, for example nn.CrossEntropyLoss(weight=weights), so mistakes on rare classes cost more. Apply heavier augmentation to underrepresented classes to increase their effective variety. Whichever you choose, stop relying on plain accuracy as your metric, since a dataset that is 95% one class gives 95% accuracy to a model that has learned nothing, and switch to per class precision and recall instead.
Q5: Should I do preprocessing in the Dataset or beforehand as a separate step?
Split it by whether the operation is random. Deterministic, expensive work belongs in a one time preprocessing pass that writes results to disk, since decoding large images, resampling audio, or parsing complex files on every epoch wastes time repeating identical work. Random augmentations must stay inside __getitem__, because their value comes from producing different output on each access. A practical pattern for large image datasets is to resize everything to a sensible maximum resolution once, save the results, and then apply random crops, flips, and rotations at load time. This keeps __getitem__ fast while preserving augmentation variety, and it often turns a data bound training run into a compute bound one.
Q6: How does this fit into a production machine learning workflow?
The Dataset class is where your training pipeline meets your actual data, which makes it one of the most important things to version and test. In production the questions become which snapshot of the data produced this model, whether preprocessing at inference time matches preprocessing at training time, and how you detect when live data starts to differ from what the model was trained on. A mismatch between training and inference transforms is one of the most common causes of a model that scores well in evaluation and poorly in deployment. These practices sit under MLOps, and the beginner's guide to MLOps covers how data versioning and pipeline reproducibility fit into the wider lifecycle. For hands on practice, the 100 Days of MLOps challenge works through data versioning, experiment tracking, and deployment as daily tasks.
Discussion