Exercises — Datasets and DataLoaders
Before we start, one symbol we will reuse everywhere. When we write we mean the floor: round down to the nearest whole number (). When we write we mean the ceiling: round up (). Picture a number line with sitting between two integer fence-posts — floor grabs the post on the left, ceiling grabs the post on the right.

Level 1 — Recognition
Exercise 1.1
A PyTorch Dataset must implement exactly two methods for a DataLoader to use it. Name them and say in one sentence what each returns.
Recall Solution
The two required methods are:
- ==
__len__()== — returns the total number of samples (an integer). The DataLoader needs this to know how many batches an epoch contains. - ==
__getitem__(idx)== — returns the single sample at positionidx, usually a tuple of data and label.
Why exactly these two? Because the DataLoader only ever needs to ask "how many are there?" (__len__) and "give me number idx" (__getitem__). Everything else — shuffling, batching, parallel loading — is built on top of just those two questions.
Exercise 1.2
For each phrase, say whether it is a job of the Dataset or the DataLoader: (a) shuffling samples each epoch, (b) loading one image from disk, (c) stacking 32 samples into one tensor, (d) defining what one sample is.
Recall Solution
- (a) shuffling → DataLoader (the "how it's delivered")
- (b) loading one image → Dataset (inside
__getitem__) - (c) stacking into a tensor (collation) → DataLoader
- (d) defining what a sample is → Dataset
Mnemonic: the Dataset answers what, the DataLoader answers how it arrives.
Level 2 — Application
Exercise 2.1
A dataset has samples and batch size .
(a) With drop_last=False, how many batches per epoch?
(b) With drop_last=True, how many batches per epoch?
(c) With drop_last=False, how many samples are in the last batch?
Recall Solution
Recall from the parent [!formula]: batches = when we keep everything, and when we drop the incomplete tail.
First compute the raw ratio: .
(a) drop_last=False keeps the leftover, so we round up:
(b) drop_last=True throws the leftover away, so we round down:
(c) The first batches hold samples. The last batch holds the remainder:
Look at figure s02: the full bars are height 32, and the short orange stub on the right is the size-8 remainder.

Exercise 2.2
You set batch_size=64 and get exactly 50 batches per epoch with drop_last=False, and the last batch is full. What is the smallest and largest possible ?
Recall Solution
means lands in the interval — any value above 49 but not above 50 rounds up to 50. But we're also told the last batch is full, i.e. is an exact multiple of 64, so .
The only multiple of 64 with is: So smallest = largest = . (If the "full last batch" condition were dropped, could range from up to .)
Level 3 — Analysis
Exercise 3.1
Loading one batch from disk takes ms of CPU work. Running that batch through the GPU takes ms. With num_workers=0 (loading and computing happen one after the other), how long does one batch take? With enough workers to fully overlap loading and compute, how long does one batch take (in steady state)? What is the speed-up?
Recall Solution
Serial (num_workers=0): the CPU loads, then the GPU computes, nothing overlaps:
Overlapped (enough workers): while the GPU chews on batch , worker processes are already loading batch . In steady state each batch's wall-clock time is the slower of the two stages, not their sum:
Speed-up:
Notice the ceiling on this idea: because loading (40 ms) is slower than compute (25 ms), the pipeline is data-bound — the GPU idles 15 ms each batch. More workers won't help beyond removing the serial stall; you'd instead need faster storage or lighter transforms. This overlap trick is exactly what powers efficient 3.3.08-Distributed-Training.
Exercise 3.2
Two engineers train the same model. Engineer A uses shuffle=True; Engineer B uses shuffle=False on data that happens to be sorted by class (all cats, then all dogs). Both use batch_size=32. Explain what goes wrong for B, referencing the mini-batch gradient formula.
Recall Solution
The mini-batch gradient is the average over the batch: This estimate is only a good stand-in for the true full-data gradient if the batch is a representative sample of the dataset. Engineer B's sorted data means an early batch is all cats. Its average gradient points "make everything look like a cat," then a later all-dogs batch yanks the weights the opposite way. The optimizer sees a violently biased, oscillating signal — high-variance, non-representative steps — and generalizes poorly.
Engineer A's shuffle makes each batch a random mix, so is an unbiased, low(er)-variance estimate of the real gradient — smooth, honest steps. This is precisely why 2.2.03-Mini-Batch-Gradient-Descent assumes i.i.d. sampling.
Level 4 — Synthesis
Exercise 4.1
You have variable-length text sequences, each of shape (length_i, 16) (16 features per token). Batches of these can't be stacked directly because rows differ in length. Write a collate_fn(batch) that pads every sequence in the batch up to the batch's longest sequence, then stacks them into one tensor. State the final tensor's shape symbolically.
Recall Solution
The problem: torch.stack demands identical shapes, but sequence lengths differ. The fix is to pad the short ones with zeros up to the batch maximum, making the batch rectangular.
import torch
import torch.nn.functional as F
def collate_fn(batch):
# batch: list of (seq, label), seq shape = (length_i, 16)
max_len = max(seq.shape[0] for seq, _ in batch) # longest in THIS batch
seqs, labels = [], []
for seq, label in batch:
pad_rows = max_len - seq.shape[0] # how many rows to add
# F.pad args go last-dim-first: (feat_left, feat_right, len_left, len_right)
padded = F.pad(seq, (0, 0, 0, pad_rows)) # pad only the length dim
seqs.append(padded)
labels.append(label)
return torch.stack(seqs), torch.tensor(labels)Final shape. With batch size and per-batch max length , the stacked tensor is: Note is chosen per batch, not globally — a batch of short sequences pads to a small , saving memory. Figure s03 shows three ragged sequences becoming one rectangle with zero-padding.

Exercise 4.2
A dataset yields samples where can be missing (returned as None). Write a collate_fn that drops samples with a missing label before stacking, and returns the batch tensor plus the count of surviving samples. What happens if a whole batch is missing labels?
Recall Solution
import torch
def collate_drop_none(batch):
kept = [(x, y) for (x, y) in batch if y is not None] # filter first
if len(kept) == 0:
return None # signal empty batch
xs = torch.stack([x for x, _ in kept])
ys = torch.tensor([y for _, y in kept])
return xs, ys, len(kept)Reasoning: we filter before stacking because torch.stack can't ignore None — it would crash. The surviving count len(kept) matters downstream: the gradient average must divide by the actual number of valid samples, not the nominal batch size , or the gradient magnitude is wrong.
Whole-batch-missing case: if every label is None, kept is empty; stacking an empty list would error, so we return None and the training loop must continue (skip that step). This is the degenerate case you must handle — silently stacking would crash mid-epoch.
Level 5 — Mastery
Exercise 5.1
Design a plan for fine-tuning a pretrained image model on a new small dataset of 5,000 labelled images that live in a single folder, filenames like class3_0421.jpg. You have one GPU and 8 CPU cores. Specify: (a) the Dataset.__getitem__ logic, (b) sensible DataLoader arguments with justification, (c) the number of batches per epoch, (d) one reason shuffle=True matters more in fine-tuning.
Recall Solution
(a) __getitem__(idx) — lazy load + parse label from filename:
def __getitem__(self, idx):
path = self.paths[idx]
img = Image.open(path).convert("RGB") # load NOW
label = int(path.split("/")[-1].split("_")[0].replace("class", ""))
if self.transform: img = self.transform(img) # augment per-access
return img, labelStoring only paths in __init__ keeps RAM constant (lazy loading); label parsing is a pure string split, so __getitem__ stays deterministic.
(b) DataLoader:
DataLoader(ds, batch_size=32, shuffle=True,
num_workers=8, pin_memory=True, drop_last=True)batch_size=32— small dataset, standard trade-off between gradient noise and speed.num_workers=8— one per core; hides disk+decode latency behind GPU compute (see Ex 3.1).pin_memory=True— page-locked RAM → faster CPU→GPU transfer (~10–20%).drop_last=True— keeps every batch the same size so batch-norm statistics stay stable.
(c) Batches per epoch with drop_last=True:
( samples used; the leftover are dropped each epoch.)
(d) Why shuffle matters more in fine-tuning: the dataset is tiny (5,000), so the model sees each sample many times across epochs. Without shuffling it would encounter them in the same order every epoch — the optimizer can memorize that sequence and overfit fast. Fresh random orderings each epoch (per 4.1.02-Transfer-Learning best practice) break this and improve generalization on the small set.
Exercise 5.2
A colleague reports their training throughput is stuck at exactly the same batches-per-second whether they set num_workers=2 or num_workers=8. Give the single most likely explanation and a concrete diagnostic they should run.
Recall Solution
Explanation: the pipeline is GPU-bound, not data-bound. From Ex 3.1, steady-state batch time is . If already at num_workers=2, the workers can supply batches faster than the GPU can consume them — so adding more workers changes nothing. The GPU is the ceiling.
Diagnostic: measure GPU utilization (e.g. nvidia-smi during training). If it sits near 100%, the GPU is the bottleneck and more workers can't help — you'd need a smaller model, mixed precision, or a bigger batch. If utilization is low with gaps, the data pipeline is stalling and you should profile __getitem__ (slow disk / heavy transforms) instead.
Recall Quick self-check (cloze)
Batches with drop_last=False :::
Batches with drop_last=True :::
Steady-state batch time with overlap :::
Where randomness lives ::: in the DataLoader's index order, never in __getitem__
Padded variable-length batch shape ::: with the per-batch max length