Module 4 — Dataset et DataLoader : alimenter l'entraînement
Un modèle bien écrit qui reçoit ses données par un pipeline mal pensé finit sous-entraîné, ou pire, entraîné sur des exemples contaminés par la validation. Ce module met en place l'infrastructure de données qui nourrira la boucle du module 5, avec un accent sur les erreurs qui ne lèvent pas d'exception mais faussent l'évaluation.
Deux abstractions, deux rôles
PyTorch sépare le stockage d'un jeu de données (comment obtenir
l'élément numéro i) et son alimentation en lots (mélange,
regroupement, parallélisme). Cette séparation permet de réutiliser un
même chargeur avec n'importe quel jeu, et de tester chaque brique
séparément.
- Un
Datasetrépond à deux questions : combien d'éléments (__len__) et quel est l'élément numéroi(__getitem__). - Un
DataLoaderenveloppe unDatasetet gère le tirage (mélange, échantillonneur), le regroupement (batch_size,collate_fn), et le parallélisme (num_workers,pin_memory).
Un Dataset personnalisé, en trente lignes
Pour Fashion-MNIST livré par torchvision, la classe existe déjà (on
l'a utilisée au module 1). Pour un jeu maison — un dossier d'images
étiquetées par le nom du sous-dossier, un fichier CSV pointant vers des
fichiers — on écrit sa propre classe.
from pathlib import Path
from torch.utils.data import Dataset
from PIL import Image
import torch
class MesImages(Dataset):
def __init__(self, racine: str, transform=None):
self.racine = Path(racine)
self.fichiers = sorted(self.racine.glob("*/*.png"))
classes = sorted({f.parent.name for f in self.fichiers})
self.classe_vers_id = {c: i for i, c in enumerate(classes)}
self.transform = transform
def __len__(self) -> int:
return len(self.fichiers)
def __getitem__(self, i: int):
chemin = self.fichiers[i]
image = Image.open(chemin).convert("L") # niveaux de gris
etiquette = self.classe_vers_id[chemin.parent.name]
if self.transform is not None:
image = self.transform(image)
return image, etiquette
__getitem__ renvoie un tuple (entrée, cible) ; DataLoader sait
empiler les entrées en un tenseur de forme (N, ...) et les cibles en
un long de forme (N,).
Transformations : composables, appliquées par exemple
torchvision.transforms fournit un langage bref pour normaliser,
redimensionner, augmenter les images. La composition se lit de haut en
bas.
from torchvision import transforms
transform_entrainement = transforms.Compose([
transforms.RandomHorizontalFlip(), # augmentation, entraînement seulement
transforms.ToTensor(), # PIL -> tenseur [0, 1]
transforms.Normalize((0.2860,), (0.3530,)), # moyenne et écart-type de Fashion-MNIST
])
transform_validation = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.2860,), (0.3530,)),
])
Deux transformations distinctes, l'entraînement avec augmentation, la validation sans. C'est un point crucial : appliquer un retournement horizontal aléatoire au calcul de la métrique de validation la rend non reproductible et incomparable d'une exécution à l'autre.
La moyenne et l'écart-type utilisés dans Normalize doivent être
calculés sur le jeu d'entraînement uniquement, puis appliqués aux
trois découpages (train, val, test). Calculer ces statistiques sur
« toutes les données » avant de découper laisse la validation
influencer la préparation, et gonfle discrètement la métrique
rapportée.
DataLoader : le vrai chevalier des données
Le DataLoader transforme un Dataset en un itérable de lots.
from torch.utils.data import DataLoader
from torchvision import datasets
train_ds = datasets.FashionMNIST(
"./donnees", train=True, download=True, transform=transform_entrainement
)
test_ds = datasets.FashionMNIST(
"./donnees", train=False, download=True, transform=transform_validation
)
train_loader = DataLoader(
train_ds,
batch_size=64,
shuffle=True,
num_workers=4,
pin_memory=True,
drop_last=True,
)
test_loader = DataLoader(
test_ds, batch_size=256, shuffle=False, num_workers=2, pin_memory=True
)
Cinq réglages méritent d'être compris et non pas recopiés.
batch_sizedéfinit le nombre d'exemples par lot. Sur portable, 64 est un compromis raisonnable pour Fashion-MNIST ; augmenter au-delà peut dépasser la mémoire GPU sur des images plus grandes.shuffle=Truemélange les indices à chaque époque, indispensable à l'entraînement. On le laisse àFalseen validation, pour un ordre reproductible.num_workersest le nombre de processus fils qui préparent les lots en parallèle. Zéro fait le travail dans le processus principal, ce qui limite le débit ; 4 à 8 est un point de départ courant sur un processeur récent. Sous Windows, le lancement de processus fils exige que le code soit protégé parif __name__ == "__main__":.pin_memory=Trueépingle la mémoire hôte, ce qui accélère le transfert vers un GPU au module 7. Sans GPU, ça ne coûte quasiment rien.drop_last=Trueignore le dernier lot incomplet, utile pour garder une taille de lot constante (indispensable pour certaines formes deBatchNormoù le petit lot fausse les statistiques).
collate_fn : personnaliser l'empilement
Par défaut, DataLoader empile les entrées avec torch.stack. Cela
suppose que tous les exemples ont la même forme. Pour des séquences de
longueurs différentes — textes, spectrogrammes, séries — il faut
fournir sa propre collate_fn, qui reçoit une liste d'exemples et
renvoie le lot.
def collate_padder(exemples):
# exemples : liste de (tenseur 1D de longueur variable, étiquette)
longueurs = torch.tensor([e[0].numel() for e in exemples])
max_L = int(longueurs.max())
lot = torch.zeros(len(exemples), max_L)
for i, (t, _) in enumerate(exemples):
lot[i, : t.numel()] = t
cibles = torch.tensor([e[1] for e in exemples])
return lot, cibles, longueurs
Pour Fashion-MNIST toutes les images font 28 par 28, on garde le
collate_fn par défaut.
Découpage train, validation, test — dans cet ordre
Un jeu de données réel se découpe en trois sous-jeux avant tout, et le
test ne se regarde qu'à la fin. torch.utils.data.random_split fait le
travail proprement.
from torch.utils.data import random_split
train_val = datasets.FashionMNIST(
"./donnees", train=True, download=True, transform=transform_entrainement
)
n_val = 6000
n_train = len(train_val) - n_val
train_ds, val_ds = random_split(
train_val, [n_train, n_val], generator=torch.Generator().manual_seed(42)
)
Le generator avec une graine fixe rend le découpage reproductible ;
un même code donnera le même train, la même validation, à chaque
exécution.
Toute transformation qui ne serait pas appliquée en production ne
devrait pas l'être en validation. C'est pourquoi le retournement
horizontal aléatoire, l'ajout de bruit, la découpe aléatoire (RandomCrop)
restent à l'entraînement. Si la mesure ne ressemble pas à la réalité,
c'est qu'elle mesure autre chose.
Vérifier un lot avant d'entraîner
Encore une inspection de trente secondes qui rattrape bien des surprises.
lot_images, lot_etiquettes = next(iter(train_loader))
print(lot_images.shape, lot_images.dtype) # torch.Size([64, 1, 28, 28]) torch.float32
print(lot_etiquettes.shape, lot_etiquettes.dtype) # torch.Size([64]) torch.int64
print(lot_images.min().item(), lot_images.max().item()) # bornes après normalisation
Un lot où toutes les étiquettes valent la même valeur trahit un mélange
mal fait — souvent un shuffle=False oublié, parfois un tampon
mémoire trop petit. Une valeur maximale au-dessus de 5 signale une
normalisation manquante.
En résumé
Datasetrépond à__len__et__getitem__;DataLoadergère lots, mélange et parallélisme. Cette séparation permet de tester chaque brique isolément.- Deux
transformdistinctes : augmentation à l'entraînement, sans augmentation en validation. La statistique deNormalizese calcule sur l'entraînement uniquement. num_workers > 0accélère le pipeline mais exigeif __name__ == "__main__":sous Windows ;pin_memory=Trueaccélère le transfert vers GPU.- Découper train, val, test avant tout avec
random_splitet une graine fixe ; le test ne se regarde qu'à la fin, jamais pour choisir un hyperparamètre.
Le module suivant assemble tenseurs, nn.Module et DataLoader dans
une boucle d'entraînement complète, la première version end-to-end
du fil rouge Fashion-MNIST.