DataLens

Datasets and DataLoaders

Wrap data in a Dataset, let a DataLoader handle batching and shuffling, and write the cleaner training loop that every PyTorch project uses.

Level: Intermediate · 8 min read

Practise this module

The questions for this lesson run in your browser and check your answer straight away.

The idea

The previous module sliced tensors by hand to make mini-batches. That works for 300 rows in memory. Real projects have data that doesn't fit in memory (thousands of images), needs per-example processing (reading a file, augmenting an image), and should load in parallel while the GPU trains. PyTorch splits this into two pieces:

A Dataset is two methods

In the last module, the training loop sliced batches out of tensors by hand. That works for small data, but it's fiddly and it doesn't scale to data that doesn't fit in memory. PyTorch's answer is to wrap the data in a Dataset: a class that knows how many examples there are and how to fetch one:

import numpy as np
import pandas as pd
import torch
from torch.utils.data import Dataset, DataLoader


class ChurnDataset(Dataset):
    def __init__(self, frame, features, target):
        self.X = torch.tensor(frame[features].to_numpy(), dtype=torch.float32)
        self.y = torch.tensor(frame[target].to_numpy(), dtype=torch.float32)

    def __len__(self):
        return len(self.y)

    def __getitem__(self, i):
        return self.X[i], self.y[i]


churn = pd.read_csv("churn.csv")
FEATURES = ["tenure_months", "orders_90d", "days_since_last_order", "support_tickets", "app_user"]
dataset = ChurnDataset(churn, FEATURES, "churned")
print(len(dataset))
x0, y0 = dataset[0]
print(x0, y0)
400
tensor([12.,  7., 47.,  1.,  0.]) tensor(0.)

That's the whole contract (Python's special methods, from the classes module). For data already in tensors, TensorDataset(X, y) does the same in one line.

A DataLoader makes batches

A Dataset hands out one example at a time. Training wants shuffled batches of them. A DataLoader sits on top of the Dataset and does exactly that:

torch.manual_seed(0)
loader = DataLoader(dataset, batch_size=64, shuffle=True)
print(len(loader))
X_batch, y_batch = next(iter(loader))
print(X_batch.shape, y_batch.shape)
print(sum(len(yb) for _, yb in loader))
7
torch.Size([64, 5]) torch.Size([64])
400

Splitting a Dataset

random_split divides a Dataset into training and validation parts:

from torch.utils.data import random_split

generator = torch.Generator().manual_seed(42)
train_set, val_set = random_split(dataset, [300, 100], generator=generator)
train_loader = DataLoader(train_set, batch_size=32, shuffle=True)
val_loader = DataLoader(val_set, batch_size=100)
print(len(train_set), len(val_set), len(train_loader), len(val_loader))
300 100 10 1

The seeded generator makes the split reproducible, like random_state in scikit-learn. (For stratified splits or preprocessing fitted on training rows only, splitting the DataFrame with scikit-learn first and building two Datasets is often simpler.)

The cleaner training loop

With loaders, the loop from the previous module becomes:

from torch import nn

mean = dataset.X[train_set.indices].mean(dim=0)
std = dataset.X[train_set.indices].std(dim=0)

torch.manual_seed(0)
model = nn.Sequential(nn.Linear(5, 16), nn.ReLU(), nn.Linear(16, 1))
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.BCEWithLogitsLoss()

for epoch in range(1, 31):
    model.train()
    for X_b, y_b in train_loader:
        optimizer.zero_grad()
        loss = loss_fn(model((X_b - mean) / std).squeeze(1), y_b)
        loss.backward()
        optimizer.step()

    model.eval()
    correct = 0
    with torch.no_grad():
        for X_b, y_b in val_loader:
            probs = torch.sigmoid(model((X_b - mean) / std).squeeze(1))
            correct += ((probs >= 0.5).float() == y_b).sum().item()
    if epoch in (1, 10, 30):
        print(f"epoch {epoch:2d}  validation accuracy {correct / len(val_set):.2f}")
epoch  1  validation accuracy 0.73
epoch 10  validation accuracy 0.86
epoch 30  validation accuracy 0.84

The loop body no longer knows how batches are made; the loader handles it. The same loop would work unchanged for a dataset of millions of images on disk.

Note the details: squeeze(1) turns the model's (batch, 1) output into (batch,) to match the targets, and the scaling statistics come from the training rows only.

Key idea

A Dataset answers "how many?" and "give me number i"; a DataLoader turns it into shuffled batches. Training loops iterate over loaders: for X_b, y_b in train_loader:.

Why this matters for clean code

Datasets separate data access from training logic. You can test a Dataset on its own (does dataset[0] return the right shapes?), swap a CSV-backed Dataset for a database-backed one without touching the loop, and reuse the same loop across projects. It's the same separation as load_data() vs analyse() in the pandas pipelines module.

Recap

Practise this module

The questions for this lesson run in your browser and check your answer straight away.