Unit 02.05: Project step: create a reusable dataloader
Everything from this module, assembled into one function you will use for the rest of the course.
One function, with the leak already closed
The function takes a DataFrame and returns training and validation loaders. The important detail is *where* the scaling statistics come from: the training rows only.
Computing a mean over the whole frame lets validation data influence the transformation applied to training data. The score improves and the improvement is fictional.
import pandas as pd
import torch
from torch.utils.data import DataLoader, TensorDataset, random_split
def make_loaders(df, feature_cols, target_col, batch_size=32, seed=0):
"""Return train/val loaders from a dataframe, with the split seeded."""
X = torch.tensor(df[feature_cols].to_numpy(), dtype=torch.float32)
y = torch.tensor(df[target_col].to_numpy(), dtype=torch.float32).unsqueeze(1)
# Standardise using TRAINING statistics only -- computing them over the
# whole frame leaks validation information into the model.
n_train = int(len(df) * 0.8)
generator = torch.Generator().manual_seed(seed)
train_ds, val_ds = random_split(TensorDataset(X, y),
[n_train, len(df) - n_train], generator=generator)
train_X = X[train_ds.indices]
mean, std = train_X.mean(0), train_X.std(0).clamp(min=1e-8)
scaled = TensorDataset((X - mean) / std, y)
train = torch.utils.data.Subset(scaled, train_ds.indices)
val = torch.utils.data.Subset(scaled, val_ds.indices)
return (DataLoader(train, batch_size=batch_size, shuffle=True),
DataLoader(val, batch_size=batch_size),
{"mean": mean.tolist(), "std": std.tolist()})
torch.manual_seed(0)
frame = pd.DataFrame({
"hours": torch.rand(200).mul(10).tolist(),
"attempts": torch.randint(1, 5, (200,)).tolist(),
"passed": torch.randint(0, 2, (200,)).tolist(),
})
train_loader, val_loader, stats = make_loaders(frame, ["hours", "attempts"], "passed")
print("train batches:", len(train_loader), " val batches:", len(val_loader))
xb, yb = next(iter(train_loader))
print("batch shapes :", tuple(xb.shape), tuple(yb.shape))
print("scaling stats came from training rows only:", [round(v, 2) for v in stats["mean"]])
The returned stats dictionary carries the training mean and standard deviation. Keep it — at prediction time you must apply the *same* transformation, and re-deriving it from new data is another route to the same leak.
The mistake this prevents
Standardising before splitting. It is one line shorter, it looks harmless, and it quietly contaminates every result that follows.
Takeaway
Split first, compute statistics on training data only, and return them so the same transformation can be reapplied later.
