Topic 10: Deep Learning
Datasets And Dataloaders in Pytorch
An interactive study of Dataset and DataLoader — the PyTorch data pipeline
- Sections marked ▶ are live — move the controls and the output recomputes.
- Blocks marked + Going deeper are optional detail — open them only if you want the mechanism behind the rule.
- Every multiple-choice question responds the moment you click an option, and explains itself.
- The closing assessment draws three questions at random — submit, then fetch another three.
- Every number below is derived from one ten-sample dataset, printed in Section 3, so nothing is asserted without being visible.
Section OneWhy batching, and why a library
Every training loop so far has pushed the whole dataset through the model in one forward pass, computed one loss, and taken one step. That is batch gradient descent. It is correct, and for any dataset of real size it is unusable.
- Memory. A forward pass holds the inputs, every intermediate activation, and the autograd graph — peak memory scales with how many samples are in flight. 100,000 images at 224×224×3 is ~15 GB before a single activation. The result is an out-of-memory crash, not slow training.
- Convergence. Full-batch buys one update per epoch. The same 25 epochs in batches of 32 gives hundreds of updates for the same total computation — and each batch gradient is noisy but unbiased, which helps the model escape shallow minima and saddle points.
The fix. Don't eat the whole pizza in one bite. Slice it. Push 32 or 64 samples through, compute the loss on the chunk, update, move on. That is mini-batch gradient descent; everything that follows is machinery for producing those chunks correctly and efficiently.
Slicing by hand gets you most of the way
batch_size = 2
n_samples = len(X_train_tensor)
for epoch in range(epochs):
# Simply walk the dataset in chunks of `batch_size`
for start_idx in range(0, n_samples, batch_size):
end_idx = start_idx + batch_size
X_batch = X_train_tensor[start_idx:end_idx]
y_batch = y_train_tensor[start_idx:end_idx]
y_pred = model(X_batch)
loss = loss_function(y_pred, y_batch.view(-1, 1))
optimizer.zero_grad()
loss.backward()
optimizer.step()
The maths here is right and the memory is bounded, so the objection to this loop is architectural, not mathematical. Four things are missing: a standard interface (this assumes an in-memory, sliceable tensor — swap in images on disk and every line changes), a home for transformations, shuffling (real files are usually sorted by class), and parallelism (the GPU idles while the CPU decodes).
PyTorch's answer is to split the problem in two: an object that knows what the data is, and an object that knows how to serve it.
A dataset of 100,000 images crashes with an out-of-memory error on the very first forward pass. What is the direct cause?
The manual slicing loop already produces correct mini-batches. What does it fundamentally not provide?
Section TwoTwo abstractions, one division of labour
PyTorch supplies two classes in torch.utils.data. They are deliberately narrow, and the discipline of keeping them narrow is the whole design.
Dataset — the catalogue
- Knows what your data is.
- Answers exactly two questions: how many samples? and give me sample i.
- Knows nothing about batches, shuffling or epochs — it is an indexed collection, nothing more.
DataLoader — the logistics layer
- Knows how to serve data.
- Decides the order indices are drawn, groups them into chunks, asks the
Datasetfor each index, stacks the results into a batch tensor. - Hands that batch to the training loop — and never inspects what the samples mean.
Going deeper responsibility split, line by line
| Responsibility | Dataset | DataLoader |
|---|---|---|
| Knowing the total number of samples | ✔ via __len__ | — |
| Retrieving one sample by index | ✔ via __getitem__ | — |
| Applying per-sample transformations | ✔ inside __getitem__ | — |
| Deciding the order indices are drawn | — | ✔ via the sampler |
| Grouping indices into batches | — | ✔ via batch_size |
| Stacking samples into a batch tensor | — | ✔ via collate_fn |
| Parallel loading across processes | — | ✔ via num_workers |
| Preparing memory for fast GPU transfer | — | ✔ via pin_memory |
DataLoader's work; the Dataset is consulted only at the __getitem__ step.Which object actually calls __getitem__, and how many times per batch?
DataLoader holds a list of indices for the batch and calls dataset[i] for each one — batch_size calls per batch. The Dataset is passive; it responds, it never initiates.Section ThreeWriting a Dataset: the three methods
A custom dataset subclasses torch.utils.data.Dataset and overrides three methods. There is no fourth. Everything else PyTorch gives you for free.
from torch.utils.data import Dataset, DataLoader
class CustomDataset(Dataset):
def __init__(self, features, labels):
self.features = features
self.labels = labels
def __len__(self):
return self.features.shape[0] # how many samples
def __getitem__(self, index):
return self.features[index], self.labels[index] # one (features, label) pair
__init__ — what you store
- Stores what is needed to locate a sample — tensors, a dataframe, file paths, a database connection. Do whole-collection conversions here (cast, reshape): once beats ten thousand times.
- It need not load the data. Store filenames and read each file only when requested — that is what keeps memory bounded.
__len__ — the contract
- Returns one integer: the number of samples.
- The loader derives the legal index range and the batch count from it. If it lies, the loader either over-runs the data or silently ignores part of it.
__getitem__ — one sample, never a batch
- Takes one index, returns one sample — conventionally
(features, label). - The batch dimension is added later, by collate. Return pre-batched data and it gets created twice — shapes silently become nonsense, with no error raised.
The dataset used throughout these notes
Ten samples, two features each, two balanced classes, generated once with a fixed random seed. Small enough to print in full, which means every claim below can be checked against this table rather than taken on trust.
| index | feature 1 | feature 2 | label | class |
|---|---|---|---|---|
| 0 | 1.0683 | −0.9701 | 1 | positive |
| 1 | −1.1402 | −0.8388 | 0 | negative |
| 2 | −2.8954 | 1.9769 | 0 | negative |
| 3 | −0.7206 | −0.9606 | 0 | negative |
| 4 | −1.9629 | −0.9923 | 0 | negative |
| 5 | −0.9382 | −0.5430 | 1 | positive |
| 6 | 1.7273 | −1.1858 | 1 | positive |
| 7 | 1.7774 | 1.5116 | 1 | positive |
| 8 | 1.8997 | 0.8344 | 1 | positive |
| 9 | −0.5872 | −1.9717 | 0 | negative |
toy_dataset = CustomDataset(X_toy, y_toy)
print('len:', len(toy_dataset), '| sample[2]:', toy_dataset[2])
len(toy_dataset)works because__len__is defined — Python's built-inlendispatches to it.toy_dataset[2]works because__getitem__is defined — square-bracket indexing dispatches to it.- These are ordinary Python protocols, not special PyTorch powers. That is exactly why the interface feels natural.
What does toy_dataset[3] return?
__getitem__, which returns exactly one sample as a tuple. The count comes from __len__, and batching happens later, in the DataLoader.Section FourTransformations: where they belong
Raw data is rarely in the form a model wants. The question is not whether to transform, but where that code lives — and the answer is inside __getitem__. Three things follow, none available if you transform the whole array up front:
- Lazy, per sample. Only the current batch is transformed, so memory stays bounded even when the transformed form is far larger than the raw one.
- Different every epoch. A random crop or flip gives a fresh version of sample 3 each epoch — exactly what augmentation needs. Transform up front and every epoch sees the identical image.
- Runs inside the workers. The cost is parallelised and hidden behind training; work done in
__init__happens once, serially, in the main process.
Going deeper typical transforms, by modality
| Modality | Typical per-sample transformations | Random each epoch? |
|---|---|---|
| Images | resize to a fixed resolution · convert to tensor · normalise channels · random crop, flip, rotation, colour jitter | Yes — augmentation is the point |
| Text | lowercase · strip punctuation · tokenise · map tokens to ids · truncate or pad to a length | Usually not |
| Tabular | cast dtype · standardise or min–max scale · encode categoricals · reshape the label | No — must be deterministic |
- Transforms that depend on statistics of the data — standardisation, scaling, vocabulary building — must be fitted on the training split only, then applied unchanged to validation and test.
- Fit before splitting and the test set has leaked into training. Nothing errors — your accuracy is simply wrong, in the flattering direction.
Which of these is unsafe to compute inside __getitem__?
__getitem__ is both wasteful and a route to leakage.Section FiveWiring the DataLoader
With a Dataset in hand, batching requires one line and no batching code of your own.
toy_loader = DataLoader(toy_dataset, batch_size=2, shuffle=False)
for batch_features, batch_labels in toy_loader:
print(batch_features.tolist(), '->', batch_labels.tolist())
With shuffle=False the batches are indices [0,1], [2,3], [4,5], [6,7], [8,9] — check them against the table in Section 3 and every value matches. Ten samples divided into batches of two gives five iterations of the loop, and len(toy_loader) returns 5.
Going deeper the control flow, step by step
What happens between for … in toy_loader and the first batch arriving:
- At the start of each epoch the loader asks its sampler for an ordering of all indices
0…9. Withshuffle=Truethat ordering is a fresh permutation; withshuffle=Falseit is0,1,2,…,9. - That ordering is divided into chunks of
batch_size. - For each index in the current chunk, the loader calls
dataset[i], receiving one(features, label)tuple per index. - The resulting list of tuples is passed to
collate_fn, which stacks them into batch tensors. - The batch is yielded to your loop body. When the chunks are exhausted, the epoch ends and step 1 repeats with a new shuffle.
Where the batch dimension comes from
__getitem__returns features of shape[2]— two features, no batch axis. Collate stacks two of those into[2, 2], and the scalar labels (shape[]) into[2].- So the batch dimension is created by stacking. It exists nowhere in the
Dataset— and every shape bug is a confusion about which side of that line you are on.
Ten samples, batch_size=3, drop_last=False. How many batches per epoch, and what is the size of the last one?
len(loader) is ⌈10/3⌉ = 4 and the sizes are 3, 3, 3, 1. Set the widget above to batch_size=3 and read it off directly.Same configuration, but now drop_last=True. How many samples does the model never see during that epoch?
drop_last=True discards the incomplete trailing batch, so one sample is skipped that epoch. With shuffling on it is a different sample each epoch, which is why the loss is usually acceptable — but on a small dataset, or with drop_last and no shuffling, you are permanently ignoring the tail of your data.Section SixShuffling and samplers
Shuffling looks like a convenience flag. It is a correctness requirement, and the flag is thin shorthand over a real object.
Why order matters
- Real files arrive sorted — by class, date, patient, source.
- Walk that order and consecutive batches are highly correlated: the model adjusts hard toward one class, then undoes it for the next, so the gradient signal oscillates instead of accumulating.
- Batch statistics — the mean and variance batch normalisation relies on — become unrepresentative of the dataset.
Our toy set is balanced 5 and 5, but not sorted. Here is what it would look like if it were — sorted by label, batches of two, no shuffling:
The sampler underneath the flag
The shuffle argument does not implement shuffling. It selects a sampler — an object whose entire job is to produce the sequence of indices the loader will consume:
Going deeper the four sampler types
| Sampler | Behaviour | Selected by |
|---|---|---|
SequentialSampler | Yields indices in the order they appear in the dataset: 0, 1, 2, …, n−1. | shuffle=False (the default) |
RandomSampler | Yields a fresh random permutation of all indices each epoch — sampling without replacement, so every sample appears exactly once. | shuffle=True |
WeightedRandomSampler | Draws indices according to per-sample weights, so under-represented classes can be over-sampled. | Passed explicitly via sampler= |
| Custom | Any object yielding indices — curriculum ordering, stratified draws, subset restriction. | Passed explicitly via sampler= |
shuffle=Trueandsampler=are mutually exclusive — passing both is an error, since they specify the same thing.- Shuffle the training loader only. Evaluation uses
shuffle=False: order does not affect the metric, but a stable order makes results reproducible.
shuffle=True is shorthand for which sampler, and what does it guarantee?
Dataset plays no part in this at all.Section Sevencollate_fn: how a list becomes a batch
After calling __getitem__ for every index in the chunk, the loader holds a plain Python list. collate_fn turns it into tensors: it stacks position-wise — all features into one tensor, all labels into another — adding a leading batch dimension to each. Traced on the first toy batch:
# What collate_fn receives — a list of two samples, each a tuple:
[ (tensor([ 1.0683, -0.9701]), tensor(1)),
(tensor([-1.1402, -0.8388]), tensor(0)) ]
# What collate_fn returns — one tuple of two stacked tensors:
( tensor([[ 1.0683, -0.9701],
[-1.1402, -0.8388]]), # shape [2, 2] = [batch, features]
tensor([1, 0]) ) # shape [2] = [batch]
Purely structural — a list of k tuples becomes a tuple of tensors with a leading dimension of k: shapes [2] and [] became [2, 2] and [2]. This is the moment the batch dimension is born.
Going deeper when the default collate is not enough
The default calls torch.stack, which requires every tensor to have identical shape. Fine for tabular data and fixed-size images. It fails for:
- Variable-length sequences. Sentences of length 7, 12 and 4 cannot be stacked. A custom collate pads each to the longest in that batch — not a global maximum, which is why padding belongs here and not in
__getitem__. - Images of differing size, where padding or bucketing beats a fixed resize.
- Structured payloads — dictionaries, graphs, variable bounding-box counts, or metadata that should stay a Python list.
- Supply your own with
collate_fn=my_collate. It receives the list, returns whatever your loop expects — that is the whole contract. - Right place for logic that must see the whole batch. Wrong place for anything concerning a single sample.
A text dataset returns token sequences of differing lengths. The default collate function raises an error. Why, and what is the fix?
__getitem__, because a single sample has no way of knowing the longest length in the batch it will end up in — and padding to a global maximum wastes computation on every short sequence.Section EightParallelism: num_workers and the pipeline effect
Correctness is settled; this is about throughput — and one common waste: an expensive accelerator idling while one CPU thread decodes a JPEG. Take 10,000 samples, batch_size=32, num_workers=4 → 313 batches per epoch.
Going deeper the four dispatch steps, in order
- Queue the work. The sampler shuffles all 10,000 indices and groups them into 313 chunks of 32.
- Dispatch. The first four chunks go to the four workers, one each. Every worker calls
__getitem__32 times, applies transforms, and runscollate_fn. - Overlap — the whole point. The first worker to finish hands its batch to your loop. While you run forward–backward–step on batch 1, the other three are still preparing batches 2, 3 and 4.
- Refill. A worker that finishes grabs the next chunk — whoever produced batch 1 picks up batch 5. Up to four batches are in preparation at any instant.
Choosing the values
num_workers=0(the default) loads inline in the main process — every millisecond decoding is a millisecond the GPU sits idle. Free for in-memory tensors; often the dominant cost for image and audio.- More is not uniformly better. Each worker is a real process with memory overhead, and every batch must be serialised back. Once preparation is hidden behind computation, extra workers add overhead and nothing else.
pin_memory=Truespeeds the host→GPU copy. Only meaningful on CUDA — on a CPU-only machine, leave itFalse.
Going deeper why pinned memory is faster
- Ordinary host memory is pageable — the OS may move it, so a GPU transfer needs an intermediate staging copy.
- Pinned (page-locked) memory cannot be moved, so the DMA engine transfers it directly and asynchronously, overlapping with computation.
What is the characteristic symptom that you are data-loading bound rather than compute bound?
num_workers.Section NineParameter reference
Every argument you will realistically use, and the question each one answers.
| Parameter | Default | What it does, and when to change it |
|---|---|---|
dataset | required | The source of samples. Must implement __len__ and __getitem__. This is the only mandatory argument. |
batch_size | 1 |
Samples per batch. Larger batches use the accelerator better and give smoother gradients, but cost memory and yield fewer updates per epoch. Start at 32 or 64. |
shuffle | False |
Reorders indices afresh each epoch. Set True for training, False for validation and test. Mutually exclusive with sampler. |
num_workers | 0 |
Worker processes preparing batches in parallel. Raise it when data preparation is expensive — decoding, augmentation, disk reads. Leave at 0 for small in-memory tensors. |
pin_memory | False |
Allocates batches in page-locked memory for faster, asynchronous host-to-device transfer. Meaningful only when training on CUDA. |
drop_last | False |
Discards the final batch when it is smaller than batch_size. Useful when a fixed batch size is required — some batch-normalisation configurations misbehave on a batch of one. |
collate_fn | default stack | Turns the list of fetched samples into a batch. Replace it for variable-length sequences, padding, or non-tensor payloads. |
sampler | None |
Explicit index-drawing strategy — class-balanced sampling, curriculum ordering, subset restriction. Overrides shuffle. |
batch_sampler | None |
Like sampler, but yields whole lists of indices, controlling batch composition directly. Mutually exclusive with batch_size, shuffle, sampler and drop_last. |
In practice most loaders use four: dataset, batch_size, shuffle, and — once the data is non-trivial — num_workers. The rest are levers for specific problems.
Section TenThe training pipeline, restructured
Introducing the loader changes the shape of the training code itself. What was one loop becomes two, nested.
for epoch in range(epochs):
y_pred = model(X_train_tensor) # the entire dataset
loss = loss_function(y_pred, y_train_tensor.view(-1, 1))
optimizer.zero_grad()
loss.backward()
optimizer.step() # 1 update per epoch
for epoch in range(epochs):
model.train() # training mode
epoch_loss = 0.0
for batch_features, batch_labels in train_loader: # INNER: one iteration per batch
y_pred = model(batch_features) # 1. forward
loss = loss_function(y_pred, batch_labels.view(-1, 1)) # 2. loss
epoch_loss += loss.item()
optimizer.zero_grad() # 3. clear old gradients
loss.backward() # 4. backpropagate
optimizer.step() # 5. update — once per BATCH
print(f'Epoch {epoch + 1} | loss = {epoch_loss / len(train_loader):.4f}')
- The inner body runs
len(train_loader)times per epoch, each a full forward–backward–update cycle. - Updates per epoch go from one to the number of batches — at no extra total computation. The same data is processed either way.
- Divide by
len(train_loader)when printing. Bareloss.item()reports the last batch only — noisy and not comparable across runs.
Push that batch size to its extremes and the three named regimes fall out of one mechanism:
batch_size=10(the whole set) → batch gradient descent, one update per epoch.batch_size=1→ stochastic gradient descent, ten noisy updates per epoch.- Anything between → mini-batch gradient descent.
- Not three algorithms. One algorithm, one knob.
Section ElevenFinal assessment
Three questions, drawn at random from across the document. Answer them, submit for a score and per-question explanations, then fetch another three — the draw differs each time, so it is worth repeating.
Question 1
Which method does the DataLoader read in order to know how many batches an epoch contains?
batch_size to determine the epoch length, which is also what len(loader) reports.Question 2
A dataset of 250 samples is loaded with batch_size=32 and drop_last=False. How many batches per epoch, and what is the last batch's size?
drop_last=True it would be 7 batches and 26 samples skipped.Question 3
Where is the batch dimension created?
[features] and leave stacked as [batch, features]. Nothing before collate has a batch axis.Question 4
Which statement about shuffle=True is correct?
shuffle=False for reproducible ordering.Question 5
Why must padding of variable-length sequences happen in collate_fn rather than __getitem__?
Question 6
Raising num_workers from 0 to 4 improves throughput most when:
__getitem__. If that method is nearly free (b) there is nothing to hide; if computation already dominates (a) the pipeline is not data-bound; and (d) is unrelated to loading entirely.Question 7
A colleague replaces a tabular Dataset with one that reads images from disk. Which parts of the codebase must change?
__init__ and __getitem__ are rewritten.Topic 10 · Deep Learning — Dataset & DataLoader · Interactive study notes