Contents

Topic 10: Deep Learning

Datasets And Dataloaders in Pytorch

An interactive study of Dataset and DataLoader — the PyTorch data pipeline

How to use these notes. They are meant to be worked through, not only read.
  • 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

Manual batching — correct, and insufficient
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.

Check yourself · 1.1

A dataset of 100,000 images crashes with an out-of-memory error on the very first forward pass. What is the direct cause?

Check yourself · 1.2

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 Dataset for 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
ResponsibilityDatasetDataLoader
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
Raw arrays CSV · images · text Dataset __len__ · __getitem__ indexed 0 … 9 Sampler order of indices Chunk by batch_size [2, 7] · [0, 4] · … __getitem__ × k one sample per index collate_fn list → tensor Batch features [B, 2] · labels [B] Training loop — DataLoader, one iteration —
The pipeline. Everything between the dashed lines is the DataLoader's work; the Dataset is consulted only at the __getitem__ step.
Check yourself · 2.1

Which object actually calls __getitem__, and how many times per batch?

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.

The complete custom Dataset
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.

indexfeature 1feature 2labelclass
01.0683−0.97011positive
1−1.1402−0.83880negative
2−2.89541.97690negative
3−0.7206−0.96060negative
4−1.9629−0.99230negative
5−0.9382−0.54301positive
61.7273−1.18581positive
71.77741.51161positive
81.89970.83441positive
9−0.5872−1.97170negative
Instantiating and probing it
toy_dataset = CustomDataset(X_toy, y_toy)
print('len:', len(toy_dataset), '| sample[2]:', toy_dataset[2])
Outputlen: 10 | sample[2]: (tensor([-2.8954, 1.9769]), tensor(0))
  • len(toy_dataset) works because __len__ is defined — Python's built-in len dispatches 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.

Interactive · Index probe

Click any point in the plot, or drag the slider — the plot, the readout and the table row above all track the same index. Note that the returned object is a tuple of two tensors, not a tensor, and that the feature tensor has shape [2], with no batch dimension anywhere in sight.

Check yourself · 3.1

What does toy_dataset[3] return?

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
ModalityTypical per-sample transformationsRandom each epoch?
Imagesresize to a fixed resolution · convert to tensor · normalise channels · random crop, flip, rotation, colour jitterYes — augmentation is the point
Textlowercase · strip punctuation · tokenise · map tokens to ids · truncate or pad to a lengthUsually not
Tabularcast dtype · standardise or min–max scale · encode categoricals · reshape the labelNo — must be deterministic
The one pitfall that matters
  • 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.
Check yourself · 4.1

Which of these is unsafe to compute inside __getitem__?

Section FiveWiring the DataLoader

With a Dataset in hand, batching requires one line and no batching code of your own.

Ten samples, batches of two
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())
Output — five batches[[1.0683, -0.9701], [-1.1402, -0.8388]] -> [1, 0] [[-2.8954, 1.9769], [-0.7206, -0.9606]] -> [0, 0] [[-1.9629, -0.9923], [-0.9382, -0.5430]] -> [0, 1] [[1.7273, -1.1858], [1.7774, 1.5116]] -> [1, 1] [[1.8997, 0.8344], [-0.5872, -1.9717]] -> [1, 0]

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:

  1. At the start of each epoch the loader asks its sampler for an ordering of all indices 0…9. With shuffle=True that ordering is a fresh permutation; with shuffle=False it is 0,1,2,…,9.
  2. That ordering is divided into chunks of batch_size.
  3. For each index in the current chunk, the loader calls dataset[i], receiving one (features, label) tuple per index.
  4. The resulting list of tuples is passed to collate_fn, which stacks them into batch tensors.
  5. 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.

Interactive · Batch builder

Each chip is one dataset index; its colour is that sample's class. Press Next epoch with shuffling on to watch the partition change — a fresh permutation is drawn every epoch, which is precisely why the model never sees the same batch composition twice.

Check yourself · 5.1

Ten samples, batch_size=3, drop_last=False. How many batches per epoch, and what is the size of the last one?

Check yourself · 5.2

Same configuration, but now drop_last=True. How many samples does the model never see during that epoch?

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:

Sorted data, shuffle=False — the pathological case

Four of the five batches are single-class. The gradient from batch 1 says "everything is negative"; the gradient from batch 4 says the opposite. Shuffling mixes the classes within each batch so that every gradient estimate points, approximately, in the direction the full-dataset gradient points.

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
SamplerBehaviourSelected by
SequentialSamplerYields indices in the order they appear in the dataset: 0, 1, 2, …, n−1.shuffle=False (the default)
RandomSamplerYields a fresh random permutation of all indices each epoch — sampling without replacement, so every sample appears exactly once.shuffle=True
WeightedRandomSamplerDraws indices according to per-sample weights, so under-represented classes can be over-sampled.Passed explicitly via sampler=
CustomAny object yielding indices — curriculum ordering, stratified draws, subset restriction.Passed explicitly via sampler=
  • shuffle=True and sampler= 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.
Check yourself · 6.1

shuffle=True is shorthand for which sampler, and what does it guarantee?

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:

Default collation, batch 1 of the toy loader
# 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.
Check yourself · 7.1

A text dataset returns token sequences of differing lengths. The default collate function raises an error. Why, and what is the fix?

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 runs collate_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.
TIME → main (train) step · batch 1 step · batch 2 step · batch 3 step · batch 4 worker 1 prep 1 prep 5 prep 9 worker 2 prep 2 prep 6 prep 10 worker 3 prep 3 prep 7 prep 11 worker 4 prep 4 prep 8 prep 12 Preparation happens underneath computation. With num_workers=0 these four rows collapse onto the main row and the accelerator waits.
The pipeline effect: worker preparation overlaps with training, so the main process rarely waits for data.

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=True speeds the host→GPU copy. Only meaningful on CUDA — on a CPU-only machine, leave it False.
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.
Check yourself · 8.1

What is the characteristic symptom that you are data-loading bound rather than compute bound?

Section NineParameter reference

Every argument you will realistically use, and the question each one answers.

ParameterDefaultWhat it does, and when to change it
datasetrequired The source of samples. Must implement __len__ and __getitem__. This is the only mandatory argument.
batch_size1 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.
shuffleFalse Reorders indices afresh each epoch. Set True for training, False for validation and test. Mutually exclusive with sampler.
num_workers0 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_memoryFalse Allocates batches in page-locked memory for faster, asynchronous host-to-device transfer. Meaningful only when training on CUDA.
drop_lastFalse 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_fndefault stack Turns the list of fetched samples into a batch. Replace it for variable-length sequences, padding, or non-tensor payloads.
samplerNone Explicit index-drawing strategy — class-balanced sampling, curriculum ordering, subset restriction. Overrides shuffle.
batch_samplerNone 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.

Before — one loop, one update per epoch
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
After — epochs outside, batches inside
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. Bare loss.item() reports the last batch only — noisy and not comparable across runs.

Interactive · Update counter

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?

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?

Question 3

Where is the batch dimension created?

Question 4

Which statement about shuffle=True is correct?

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:

Question 7

A colleague replaces a tabular Dataset with one that reads images from disk. Which parts of the codebase must change?

Topic 10 · Deep Learning — Dataset & DataLoader · Interactive study notes