Module 10 — Premier réseau entraîné de bout en bout
Neuf modules de mécanismes. Ce module les assemble en un projet complet et exécutable : un classifieur sur données tabulaires, du prétraitement à la sauvegarde. Chaque décision du code renvoie au module qui la justifie.
L'architecture du projet
L'ordre des étapes n'est pas négociable, et il reprend directement les acquis du cours précédent : découper avant de prétraiter, ajuster le prétraitement sur l'entraînement seul, puis construire le réseau.
import torch
import torch.nn as nn
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# 1. Decouper d'abord, pretraiter ensuite : la regle du cours precedent.
X_tr, X_val, y_tr, y_val = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
# 2. Le normaliseur s'ajuste sur l'entrainement seul, sinon il y a fuite.
echelle = StandardScaler().fit(X_tr)
X_tr = echelle.transform(X_tr)
X_val = echelle.transform(X_val)
# 3. Tenseurs puis chargeurs. Melanger l'entrainement, jamais la validation.
def en_tenseurs(X, y):
return torch.utils.data.TensorDataset(
torch.tensor(X, dtype=torch.float32),
torch.tensor(y, dtype=torch.long),
)
charg_tr = torch.utils.data.DataLoader(en_tenseurs(X_tr, y_tr), batch_size=64, shuffle=True)
charg_val = torch.utils.data.DataLoader(en_tenseurs(X_val, y_val), batch_size=256)
Le réseau
class Classifieur(nn.Module):
def __init__(self, n_entrees, n_classes, largeur=128, abandon=0.3):
super().__init__()
self.reseau = nn.Sequential(
# Pas de biais : le parametre beta de la normalisation le remplace.
nn.Linear(n_entrees, largeur, bias=False),
nn.BatchNorm1d(largeur),
nn.ReLU(),
nn.Dropout(abandon),
nn.Linear(largeur, largeur // 2, bias=False),
nn.BatchNorm1d(largeur // 2),
nn.ReLU(),
nn.Dropout(abandon),
# Sortie en scores bruts : la perte applique la softmax elle-meme.
nn.Linear(largeur // 2, n_classes),
)
def forward(self, x):
return self.reseau(x)
appareil = "cuda" if torch.cuda.is_available() else "cpu"
modele = Classifieur(X_tr.shape[1], len(np.unique(y))).to(appareil)
Trois décisions de ce bloc méritent d'être explicitées. L'ordre linéaire, normalisation, activation, abandon suit la convention du module 8. Le bias=False s'explique par le paramètre de la normalisation, qui remplit déjà ce rôle. Et la dernière couche ne porte aucune activation : CrossEntropyLoss attend les scores bruts et applique la softmax de façon numériquement stable, comme l'expliquait le module 3.
La boucle d'entraînement
critere = nn.CrossEntropyLoss()
optimiseur = torch.optim.AdamW(modele.parameters(), lr=3e-4, weight_decay=0.01)
planificateur = torch.optim.lr_scheduler.CosineAnnealingLR(optimiseur, T_max=100)
meilleure_perte, patience, attente = float("inf"), 10, 0
historique = {"train": [], "val": []}
for epoque in range(100):
# --- Entrainement : abandon actif, statistiques de lot. ---
modele.train()
perte_tr = 0.0
for X_lot, y_lot in charg_tr:
X_lot, y_lot = X_lot.to(appareil), y_lot.to(appareil)
optimiseur.zero_grad() # sinon les gradients s'accumulent
perte = critere(modele(X_lot), y_lot)
perte.backward()
nn.utils.clip_grad_norm_(modele.parameters(), 1.0) # module 6
optimiseur.step()
perte_tr += perte.item() * X_lot.size(0)
# --- Validation : abandon desactive, moyennes courantes. ---
modele.eval()
perte_val, justes = 0.0, 0
with torch.no_grad():
for X_lot, y_lot in charg_val:
X_lot, y_lot = X_lot.to(appareil), y_lot.to(appareil)
sorties = modele(X_lot)
perte_val += critere(sorties, y_lot).item() * X_lot.size(0)
justes += (sorties.argmax(1) == y_lot).sum().item()
perte_tr /= len(charg_tr.dataset)
perte_val /= len(charg_val.dataset)
historique["train"].append(perte_tr)
historique["val"].append(perte_val)
planificateur.step()
# --- Arret anticipe : on conserve les poids du meilleur passage. ---
if perte_val < meilleure_perte:
meilleure_perte, attente = perte_val, 0
torch.save(modele.state_dict(), "meilleur_modele.pt")
else:
attente += 1
if attente >= patience:
print(f"arret anticipe a l'epoque {epoque}")
break
if epoque % 10 == 0:
exactitude = justes / len(charg_val.dataset)
print(f"epoque {epoque:3d} | train {perte_tr:.4f} | val {perte_val:.4f} | exactitude {exactitude:.3f}")
Quatre points de vigilance concentrés dans cette boucle, tous issus des modules précédents. Le basculement train() et eval() conditionne le comportement de l'abandon et de la normalisation (modules 7 et 8). Le zero_grad() évite l'accumulation des gradients d'une itération à l'autre. Le torch.no_grad() en validation économise mémoire et calcul en n'enregistrant pas le graphe. Et le planificateur avance une fois par époque, non par lot.
Vérifier, puis sauvegarder
Avant de lancer ces cent époques, appliquez le test du module 9 : surajuster dix observations. S'il échoue, corrigez avant d'aller plus loin.
Pour le déploiement, on recharge les meilleurs poids et l'on sauvegarde l'ensemble de ce qui est nécessaire à la reproduction :
modele.load_state_dict(torch.load("meilleur_modele.pt"))
modele.eval()
torch.save({
"poids": modele.state_dict(),
"architecture": {"n_entrees": X_tr.shape[1], "largeur": 128, "abandon": 0.3},
"echelle": {"moyenne": echelle.mean_, "ecart_type": echelle.scale_},
"version_torch": torch.__version__,
}, "modele_complet.pt")
Sauvegarder le normaliseur avec les poids est indispensable, et c'est un oubli fréquent. Sans la moyenne et l'écart-type d'entraînement, le modèle recevra en production des données à une échelle différente de celle qu'il a apprise, et ses prédictions n'auront aucun sens. C'est exactement la logique de la chaîne de traitement du cours précédent, transposée à PyTorch.
Ce code constitue un socle solide, mais l'ordre des améliorations compte. Cherchez d'abord le bon taux d'apprentissage, qui rapporte plus que tout le reste. Puis la largeur et la profondeur, en surveillant l'écart entre les deux courbes. Puis le taux d'abandon et la pénalité de poids conjointement. N'ajoutez de la complexité architecturale qu'ensuite. Et gardez en tête le point du module 1 : sur données tabulaires, un gradient boosting bien réglé battra souvent ce réseau — la question à se poser avant de commencer est celle de la nature des données.
En résumé
- Découper avant de prétraiter, et ajuster le normaliseur sur l'entraînement seul : la règle du cours précédent s'applique inchangée.
- L'ordre linéaire, normalisation, activation, abandon, avec
bias=Falseavant une normalisation et aucune activation en sortie. - La boucle exige
train()eteval()au bon endroit,zero_grad(),no_grad()en validation, et un planificateur avancé par époque. - Sauvegarder les poids et le normaliseur ensemble ; sans les statistiques d'échelle, le modèle est inutilisable en production.
Dernière étape : la récapitulation et l'examen de 40 questions.