Dataset to First Epoch: TensorDataset, DataLoader, and the Shapes You Must Check
PyTorch’s training loop consumes batches, and DataLoader is the component that turns raw arrays into batches: it slices, shuffles, casts, and optionally prefetches in worker processes. The contract any dataset must satisfy is two methods — len() and getitem(i) — and for in-memory arrays you never have to write them yourself:
import torch
from torch.utils.data import DataLoader, TensorDataset, random_split
X = torch.tensor(features, dtype=torch.float32) # (N, 20)
y = torch.tensor(targets, dtype=torch.float32).unsqueeze(1) # (N, 1)
ds = TensorDataset(X, y)
train_ds, val_ds = random_split(ds, [0.8, 0.2],
generator=torch.Generator().manual_seed(42))
train_dl = DataLoader(train_ds, batch_size=64, shuffle=True)
val_dl = DataLoader(val_ds, batch_size=128) # no shuffle for evaluation
for xb, yb in train_dl:
print(xb.shape, yb.shape) # torch.Size([64, 20]) torch.Size([64, 1])
break
Data that does not fit in one tensor — images on disk, variable-length records — means writing the class yourself:
class MyDataset(torch.utils.data.Dataset):
def __init__(self, records, transform=None):
self.records = records
self.transform = transform
def __len__(self):
return len(self.records)
def __getitem__(self, i):
rec = self.records[i]
x = self.transform(rec['signal']) if self.transform else rec['signal']
return torch.tensor(x, dtype=torch.float32), rec['label']
The default collate function stacks the per-item tensors into a batch dimension — that is where (B, ...) appears. For ragged sequences you replace collate_fn, usually by padding to the longest item in the batch.
Check the first batch’s shapes before training anything. A (64, 1) target fed to CrossEntropyLoss, or a float where the loss wants Long, fails on step one — far cheaper than 30 epochs later. Then get the loop right:
model = MLP(n_in=20, n_hidden=64, n_out=1)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = torch.nn.MSELoss()
for epoch in range(30):
model.train()
total, count = 0.0, 0
for xb, yb in train_dl:
pred = model(xb)
loss = loss_fn(pred, yb)
opt.zero_grad()
loss.backward()
opt.step()
total += loss.item() * xb.shape[0] # weight by batch size
count += xb.shape[0]
print(f'epoch {epoch} train_mse={total / count:.4f}')
Two settings carry most of the decision weight. shuffle=True belongs on the training loader only: SGD assumes each mini-batch gradient is an independent estimate of the population gradient, and a fixed ordering feeds correlated batches that let the model settle into oscillations tracking the data order. Evaluation loaders stay unshuffled — order does not matter there, and determinism makes runs comparable. And batch size: 32 to 256 is the usual tabular range, larger batches generally want a scaled learning rate, and drop_last=True avoids a final one-sample batch that wrecks BatchNorm statistics — a preview of the mode-sensitive layers in the next chapter.
Two operational knobs matter once the loop runs. num_workers spawns subprocesses that load and collate the next batch while the GPU trains on the current one; on Windows, where spawning is the process start method, keep DataLoader construction and the training loop inside an if name == 'main': guard or the workers fail before they start. persistent_workers=True keeps those workers alive across epochs instead of respawning them per pass — a real speedup when data loading is the bottleneck. Neither knob changes what the model sees; they change how long the GPU waits for it. And note what random_split did back at the top: it slices one dataset into index views rather than copying tensors, so memory stays flat no matter how you divide it.
One habit from the very first run: decide the device once and apply it everywhere, which is the third chapter’s subject. The middle three lines of the loop — zero_grad, backward, step — are dissected in Backpropagation and the Training Loop; everything from here on assumes that loop is muscle memory.
nn.Module: Registration, __call__, and What train() and eval() Actually Toggle
An nn.Module is a container PyTorch can inspect. Subclass it and assign the layers in init:
class MLP(torch.nn.Module):
def __init__(self, n_in, n_hidden, n_out):
super().__init__()
self.fc1 = torch.nn.Linear(n_in, n_hidden)
self.fc2 = torch.nn.Linear(n_hidden, n_out)
def forward(self, x):
return self.fc2(torch.relu(self.fc1(x)))
model = MLP(20, 64, 3)
print(sum(p.numel() for p in model.parameters())) # 1539 = (20·64+64) + (64·3+3)
Assignment is registration. Putting an nn.Linear on self inserts it into the module’s internal _modules dict, so model.parameters() recurses into it, the optimizer sees it, and .to(device), .train(), and state_dict() all reach it. A raw torch.nn.Parameter stored on self is registered too. But a plain tensor — self.scale = torch.ones(3) — is invisible machinery: it never appears in parameters(), receives no gradients, never moves with .to(device), and trains nothing. The model builds, the forward pass runs, the loss decreases, and your scale stays exactly 1. The reveal is one line: state_dict() lists registered tensors and omits the impostor. When a tensor is meant to be learned, use nn.Parameter; when it is meant to be saved but not trained — a counter, a mask — register_buffer('name', tensor) puts it in state_dict without making it trainable.
The arithmetic from Perceptron and Multilayer Networks still audits the module: (20·64 + 64) + (64·3 + 3) = 1,344 + 195 = 1,539 trainable parameters, and named_parameters() prints each name exactly as the checkpoint will store it — a ten-second pre-training check that every intended layer registered and no bare tensor is along for the ride.
Calling the model is another place where the wrapper matters. model(x) invokes call, which runs pre-hooks and post-hooks around your forward() — the mechanism mixed-precision wrappers, profilers, gradient checkpointing, and quantization hooks all rely on. Calling model.forward(x) directly bypasses that machinery and silently disables anything that instrumented the module. Framework code, and other people’s models, will assume you called the module: always call model(x).
Then the two flags interviewers know to ask about. model.train() does not mean start training, and model.eval() does not mean stop — neither touches gradients or the optimizer. They set a mode flag that specific submodules read:
| Submodule | train() | eval() |
|---|---|---|
| Dropout | applies random masks each pass | identity — deterministic scaling |
| BatchNorm | uses the current batch’s statistics | uses stored running_mean/running_var |
| RNN dropout | applied per time step per config | off unless explicitly forced |
Mis-set the flags and the symptoms are specific. Forget model.eval() before validation and your metrics fluctuate between runs on identical weights and data — dropout is randomly deleting units while you measure, and BatchNorm mixes each batch’s statistics into the score. Forget to switch back to model.train() after evaluating and dropout stays active during training: convergence slows in a way that looks exactly like a learning-rate problem but is not. Neither mode flag touches autograd — that is torch.no_grad(), a separate mechanism:
model.eval()
with torch.no_grad():
for xb, yb in val_dl:
pred = model(xb) # no graph retained: less memory, faster
Train and eval decide what the layers compute; no_grad decides whether the graph is recorded at all. Use all three, in that combination, every time you evaluate. For fine-tuning, the related lever is requires_grad_(False) on the backbone: frozen weights receive no gradients, appear as non-trainable in the optimizer’s parameter groups, and skip their share of the backward cost — which is how a fine-tune of a large model fits on hardware that could never retrain it from scratch. The next step is making the whole workflow survive a crash — checkpoints and devices — which is also where the pieces assemble into the pipeline you will reuse for Convolution and Pooling.
Checkpoints That Resume: state_dict, Optimizer State, and Device Discipline
model.state_dict() returns an OrderedDict mapping names to tensors: fc1.weight as (64, 20), fc1.bias as (64,), fc2.weight, fc2.bias — and any registered buffers, such as BatchNorm’s running_mean, running_var, and num_batches_tracked. Buffers matter more than their quiet name suggests: they are state the model uses at inference but never trains, so a checkpoint that loses them hands your validation pass wrong statistics with no error at all. Save the dict, and load it into an identical architecture:
torch.save(model.state_dict(), 'best.pt')
clone = MLP(20, 64, 3) # architecture must exist before loading
clone.load_state_dict(torch.load('best.pt', weights_only=True))
Save state_dict, not the module. torch.save(model) pickles the entire object including the class’s import path and code version — it breaks the moment the class moves, gets renamed, or the PyTorch version changes, and unpickling files from the internet executes arbitrary code, which is exactly why weights_only=True exists and should be your default. A flat name-to-tensor map has none of those problems.
Resuming interrupted training, though, needs more than weights. Adam and AdamW keep per-parameter moment estimates in the optimizer — momentum accumulated over thousands of steps — and a fresh optimizer starts those at zero, so a resumed run loses its accumulated direction and the loss bumps before recovering. Persist all of it together:
torch.save({
'epoch': 37,
'model': model.state_dict(),
'opt': opt.state_dict(),
'best_val': 0.812,
}, 'ckpt.pt')
ckpt = torch.load('ckpt.pt', weights_only=False, map_location=device)
model.load_state_dict(ckpt['model'])
opt.load_state_dict(ckpt['opt'])
start_epoch = ckpt['epoch'] + 1
Note the weights_only exception: optimizer state_dicts contain more than plain tensors, so resuming needs weights_only=False — accept that only for checkpoints you produced yourself.
load_state_dict defaults to strict=True, which errors when the checkpoint’s keys and the live architecture disagree — a missing head or a renamed layer fails loudly instead of silently leaving those parameters at random initialization. And after any load, run one validation batch through the restored model and compare its loss against the recorded best_val: that single check confirms weights, mode flags, and preprocessing all line up before you trust a resumed run’s numbers.
Which checkpoint should exist at the end is a separate decision, and the default is wrong. The last epoch is rarely the best one, so track the validation metric and save on improvement:
best = float('inf')
for epoch in range(epochs):
train_one_epoch(model, train_dl, opt, loss_fn)
val = evaluate(model, val_dl) # eval() + no_grad, from chapter two
if val < best:
best = val
torch.save(model.state_dict(), 'best.pt')
Report results from the best checkpoint, not the final weights — otherwise your quoted number is a coin flip on where training happened to end.
Last, device discipline, the most common runtime error in PyTorch code. The model moves once, every batch moves every loop:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
for xb, yb in train_dl:
xb, yb = xb.to(device), yb.to(device) # forgetting this line raises:
# Expected all tensors to be on the same device, but found at least two devices
The error is honest: the model’s weights live on the GPU and the batch arrived on the CPU, or vice versa. And when loading a checkpoint written on a GPU machine onto a CPU-only one, torch.load(..., map_location='cpu') remaps the stored device tags instead of failing to allocate CUDA memory that does not exist. With datasets feeding batches, modules owning parameters, the paired loss from Output Layers and Loss Pairings getting backward()’d by the loop in Backpropagation and the Training Loop, and checkpoints preserving all of it, the workflow is complete — the same skeleton runs the convolutional models in Convolution and Pooling.
Quiz
1. Why is shuffle=True correct for the training DataLoader and wrong for the validation DataLoader?
2. An nn.Module assigns self.scale = torch.ones(3) in __init__. What is true of self.scale?
3. Validation metrics fluctuate between runs despite identical weights, data, and code. Most likely cause?
4. You resume an interrupted Adam run. What must be restored for it to continue as if uninterrupted?
Flashcards
Question
What two methods must a custom PyTorch Dataset implement, and what does the DataLoader add on top?
Click to reveal answer
Answer
__len__ and __getitem__. The DataLoader turns items into batches via the collate function, shuffles the training order each epoch through a sampler, optionally drops the uneven last batch, and prefetches with worker processes.
Question
What is the difference between calling model(x) and model.forward(x)?
Click to reveal answer
Answer
model(x) invokes __call__, which runs pre-hooks and post-hooks around forward() — the machinery mixed-precision wrappers, profilers, and quantization rely on. Calling forward() directly bypasses it and silently disables anything that instrumented the module. Always call the module.
Question
What do model.train() and model.eval() actually change, and what do they not change?
Click to reveal answer
Answer
A mode flag submodules read: dropout applies random masks in train and acts as a scaled identity in eval; BatchNorm uses batch statistics in train and stored running averages in eval. They never affect gradients or the optimizer — that is torch.no_grad(), which controls graph recording instead.
Question
What belongs in a checkpoint that resumes training faithfully?
Click to reveal answer
Answer
model.state_dict(), optimizer.state_dict(), the epoch counter, and tracked metrics. Adam/AdamW store per-parameter moment estimates in the optimizer; restoring only the weights zeroes that state and causes a loss bump on resume. Buffers such as BatchNorm running stats travel in the state_dict.
Question
Why save state_dict instead of the whole model object?
Click to reveal answer
Answer
state_dict is a flat name-to-tensor map: portable, architecture-explicit, and loadable with weights_only=True for safety. Pickling the full module freezes the class import path and code version — it breaks when the class moves or PyTorch upgrades, and unpickling untrusted files executes arbitrary code.
Revision Notes
Key Takeaways
- 1. A Dataset needs only __len__ and __getitem__; the DataLoader supplies batching, training-only shuffling, and prefetch — check the first batch’s shapes before training
- 2. Assignment to self inside nn.Module is what registers parameters: plain tensors train nothing and survive nowhere, which state_dict() exposes in one line
- 3. model(x) runs hooks via __call__; model.forward(x) bypasses them — and train()/eval() toggle dropout and BatchNorm behavior, never gradients
- 4. Checkpoints that resume faithfully carry model + optimizer state_dicts plus the epoch, because Adam’s momentum lives in the optimizer, not the model
- 5. Save state_dict with weights_only=True by default, track best-validation rather than last-epoch weights, and move model and every batch to one device explicitly
Interview Tips
- • "Write me a training loop from scratch" — DataLoader iteration, model(x), loss, zero_grad, backward, step, plus train()/eval() and no_grad around validation; the flags are what separate a complete answer from a memorized four lines
- • "Why is my validation loss noisy across identical runs?" — model.eval() missing: dropout randomness and BatchNorm batch statistics leak into the metric; mention no_grad covers memory, not determinism of layers
- • "How would you resume this training run after a crash?" — checkpoint model + optimizer + epoch, note that Adam moments live in the optimizer, and mention weights_only=True hygiene and map_location for CPU-only machines
- • "A student stores a tensor on self in their Module and it never changes — why?" — bare tensors are not registered; use nn.Parameter to train it or register_buffer to save it; offer state_dict() as the one-line diagnostic
Cheat Sheet
Building a Network in PyTorch
Data pipeline
ds = TensorDataset(X.float(), y.float()) # or a Dataset with __len__/__getitem__
train_dl = DataLoader(ds, batch_size=64, shuffle=True) # shuffle: train only
val_dl = DataLoader(val_ds, batch_size=128) # deterministic
# check xb.shape / yb.dtype on the first batch BEFORE training
Module rules
- Assign nn.Linear (or nn.Parameter) to self in init → registered → optimized, moved, saved
- Plain tensor attribute → invisible: no grads, not in state_dict; use register_buffer for saved-but-frozen state
- Call model(x), never model.forward(x) — call runs the hooks frameworks depend on
- model.train()/eval(): toggle dropout randomness and BatchNorm statistics only; gradients unaffected (that is no_grad)
Checkpoint recipe
torch.save({'epoch': e, 'model': model.state_dict(),
'opt': opt.state_dict(), 'best_val': v}, 'ckpt.pt')
ckpt = torch.load('ckpt.pt', weights_only=False, map_location=device)
model.load_state_dict(ckpt['model']); opt.load_state_dict(ckpt['opt'])
# ship/report the best-validation checkpoint, not the last epoch
Devices
- model = model.to(device) once; xb, yb = xb.to(device), yb.to(device) every batch
- Loading a GPU checkpoint on a CPU box: torch.load(path, map_location='cpu')