Entraîner un modèle PyTorch : Loss, optimizer et training loop

tl;dr: Entraîner modèle PyTorch : (1) Loss : CrossEntropyLoss (classif), MSELoss (régression), BCEWithLogitsLoss (binaire). (2) Optimizer : Adam (populaire), AdamW (weight decay). (3) Loop : forward→loss→backward()→step()→zero_grad(). (4) Validation. (5) LR scheduling : StepLR, CosineAnnealing. (6) Save : torch.save().

Maintenant que vous savez construire des réseaux de neurones, il est temps de les entraîner ! L’entraînement consiste à ajuster les poids du réseau pour minimiser une fonction de perte (loss) sur vos données. Dans cet article, nous allons couvrir les loss functions, les optimizers, et implémenter une boucle d’entraînement complète.

Vue d’ensemble du processus d’entraînement

L’entraînement d’un réseau de neurones suit ce cycle :

  1. Forward pass : Passer les données dans le modèle → obtenir prédictions
  2. Calculer la loss : Comparer prédictions vs vraies valeurs
  3. Backward pass : Calculer les gradients (backpropagation)
  4. Update des poids : Ajuster les paramètres avec l’optimizer
  5. Répéter pour toutes les données (epoch)

Schéma du cycle d’entraînement PyTorch

Loss functions : Mesurer l’erreur

La loss function (fonction de perte) mesure à quel point les prédictions du modèle s’écartent des vraies valeurs. L’objectif de l’entraînement est de minimiser cette loss.

Classification multi-classe : CrossEntropyLoss

La loss la plus utilisée pour classification multi-classe (MNIST, ImageNet, etc.) :

import torch
import torch.nn as nn

# CrossEntropyLoss = LogSoftmax + NLLLoss
criterion = nn.CrossEntropyLoss()

# Prédictions du modèle (logits)
outputs = torch.tensor([[2.0, 1.0, 0.1],   # Prédiction classe 0
                        [0.5, 2.5, 0.2]])  # Prédiction classe 1

# Vraies classes (indices)
targets = torch.tensor([0, 1])

# Calculer la loss
loss = criterion(outputs, targets)
print(f"CrossEntropyLoss : {loss.item():.4f}")  # 0.4170

# Note : pas besoin d'appliquer softmax avant !
# CrossEntropyLoss le fait automatiquement
💡 CrossEntropyLoss combine LogSoftmax + NLLLoss en une seule opération optimisée et numériquement stable. Important : Les outputs doivent être des logits (scores bruts), pas des probabilités ! N’appliquez pas softmax avant.

Avec poids de classes (classes déséquilibrées)

# Dataset déséquilibré : classe 0 = 90%, classe 1 = 10%
# Donner plus d'importance à la classe minoritaire
class_weights = torch.tensor([1.0, 9.0])  # Classe 1 pèse 9x plus
criterion = nn.CrossEntropyLoss(weight=class_weights)

outputs = torch.randn(32, 2)  # 32 exemples, 2 classes
targets = torch.randint(0, 2, (32,))
loss = criterion(outputs, targets)
print(f"Weighted loss : {loss.item():.4f}")

Classification binaire : BCEWithLogitsLoss

Pour classification binaire (spam/pas spam, chien/chat) :

# BCEWithLogitsLoss = Sigmoid + BCELoss
criterion = nn.BCEWithLogitsLoss()

# Prédictions (logits)
outputs = torch.tensor([[2.0], [-1.0], [0.5]])  # 3 exemples

# Vraies valeurs (0 ou 1)
targets = torch.tensor([[1.0], [0.0], [1.0]])

loss = criterion(outputs, targets)
print(f"BCEWithLogitsLoss : {loss.item():.4f}")

# Alternative si vous avez déjà des probabilités (après sigmoid)
outputs_probs = torch.sigmoid(outputs)
criterion_bce = nn.BCELoss()
loss_bce = criterion_bce(outputs_probs, targets)
🔎 Tip
Préférez BCEWithLogitsLoss à BCELoss car elle combine sigmoid + BCE en une seule opération, ce qui est plus stable numériquement et évite des problèmes de gradient.

Régression : MSELoss et L1Loss

Pour prédire des valeurs continues (prix, température, âge) :

# Mean Squared Error (L2)
mse_criterion = nn.MSELoss()

predictions = torch.tensor([2.5, 0.0, 2.1, 7.8])
targets = torch.tensor([3.0, -0.5, 2.0, 8.0])

mse_loss = mse_criterion(predictions, targets)
print(f"MSE Loss : {mse_loss.item():.4f}")  # 0.0650

# Mean Absolute Error (L1) - plus robuste aux outliers
l1_criterion = nn.L1Loss()
l1_loss = l1_criterion(predictions, targets)
print(f"L1 Loss : {l1_loss.item():.4f}")  # 0.2500

# Smooth L1 Loss (Huber) - compromis entre MSE et L1
smooth_l1 = nn.SmoothL1Loss()
smooth_loss = smooth_l1(predictions, targets)
print(f"Smooth L1 Loss : {smooth_loss.item():.4f}")

Autres loss functions utiles

# KL Divergence (pour distributions de probabilités)
kl_div = nn.KLDivLoss()

# Cosine Embedding Loss (pour similarity learning)
cosine_loss = nn.CosineEmbeddingLoss()

# Triplet Loss (pour face recognition, retrieval)
triplet_loss = nn.TripletMarginLoss()

# CTC Loss (pour speech recognition, OCR)
ctc_loss = nn.CTCLoss()

Loss personnalisée

Vous pouvez créer vos propres loss functions :

def custom_loss(predictions, targets):
    """
    Custom loss : MSE + régularisation L1
    """
    mse = ((predictions - targets) ** 2).mean()
    l1_reg = predictions.abs().mean()
    return mse + 0.01 * l1_reg

# Utilisation
predictions = torch.randn(32, 10)
targets = torch.randn(32, 10)
loss = custom_loss(predictions, targets)
print(f"Custom loss : {loss.item():.4f}")

Optimizers : Ajuster les poids

L’optimizer utilise les gradients calculés par backpropagation pour mettre à jour les poids du modèle.

SGD (Stochastic Gradient Descent)

L’optimizer le plus basique : w = w - lr * gradient

import torch.optim as optim

model = nn.Sequential(
    nn.Linear(784, 256),
    nn.ReLU(),
    nn.Linear(256, 10)
)

# SGD basique
optimizer = optim.SGD(model.parameters(), lr=0.01)

# SGD avec momentum (accélère la convergence)
optimizer = optim.SGD(
    model.parameters(),
    lr=0.01,
    momentum=0.9,        # Momentum (0.9 est standard)
    weight_decay=1e-4    # L2 regularization
)

Adam (Adaptive Moment Estimation)

Le plus populaire : adapte le learning rate par paramètre

# Adam : combine momentum + RMSprop
optimizer = optim.Adam(
    model.parameters(),
    lr=1e-3,                    # Learning rate (0.001 par défaut)
    betas=(0.9, 0.999),         # Momentum coefficients
    eps=1e-8,                   # Stabilité numérique
    weight_decay=0              # L2 regularization (0 = pas de reg)
)

AdamW (Adam avec Weight Decay Découplé)

Version améliorée d’Adam, recommandée pour Transformers :

# AdamW : weight decay découplé (meilleure régularisation)
optimizer = optim.AdamW(
    model.parameters(),
    lr=1e-3,
    betas=(0.9, 0.999),
    eps=1e-8,
    weight_decay=0.01          # Weight decay séparé du gradient
)
🔎 Tip
Quel optimizer choisir ? Adam/AdamW : Défaut pour la plupart des cas (CNN, Transformer, MLP). Convergence rapide, peu de tuning. SGD avec momentum : Peut donner de meilleurs résultats finaux sur CNN (ImageNet) mais nécessite plus de tuning. AdamW : Préféré pour Transformers/NLP. RMSprop : Bon pour RNN.

Autres Optimizers

# RMSprop (pour RNNs)
optimizer = optim.RMSprop(model.parameters(), lr=0.01, alpha=0.99)

# Adagrad (learning rate décroissant)
optimizer = optim.Adagrad(model.parameters(), lr=0.01)

# AdaGrad (variante améliorée)
optimizer = optim.Adadelta(model.parameters(), lr=1.0)

Learning Rate par groupe de paramètres

Différents learning rates pour différentes parties du modèle :

# Exemple : fine-tuning avec LR différencié
optimizer = optim.Adam([
    {'params': model[0].parameters(), 'lr': 1e-5},  # Première couche : LR faible
    {'params': model[2].parameters(), 'lr': 1e-3}   # Dernière couche : LR élevé
])

La boucle d’entraînement (Training Loop)

Maintenant, assemblons tout dans une boucle d’entraînement complète !

Training loop basique

import torch
import torch.nn as nn
import torch.optim as optim

# 1. Préparer le modèle, loss, optimizer
model = nn.Sequential(
    nn.Linear(784, 256),
    nn.ReLU(),
    nn.Dropout(0.2),
    nn.Linear(256, 10)
)

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)

# 2. Données d'entraînement (exemple simplifié)
train_data = torch.randn(1000, 784)  # 1000 exemples
train_labels = torch.randint(0, 10, (1000,))

# 3. Training loop
num_epochs = 10
batch_size = 32

model.train()  # Mode entraînement

for epoch in range(num_epochs):
    epoch_loss = 0.0
    num_batches = len(train_data) // batch_size

    for i in range(num_batches):
        # Mini-batch
        start_idx = i * batch_size
        end_idx = start_idx + batch_size

        inputs = train_data[start_idx:end_idx]
        targets = train_labels[start_idx:end_idx]

        # Forward pass
        outputs = model(inputs)
        loss = criterion(outputs, targets)

        # Backward pass
        optimizer.zero_grad()  # Reset gradients
        loss.backward()        # Calculer gradients
        optimizer.step()       # Update poids

        epoch_loss += loss.item()

    # Stats epoch
    avg_loss = epoch_loss / num_batches
    print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {avg_loss:.4f}")

Training loop avec DataLoader

Version réaliste avec PyTorch DataLoader :

from torch.utils.data import TensorDataset, DataLoader

# Créer un Dataset
dataset = TensorDataset(train_data, train_labels)

# DataLoader (gère batching, shuffling, parallélisation)
train_loader = DataLoader(
    dataset,
    batch_size=32,
    shuffle=True,      # Mélanger les données
    num_workers=2      # Paralléliser le chargement
)

# Training loop
def train_one_epoch(model, dataloader, criterion, optimizer, device):
    model.train()
    total_loss = 0.0

    for batch_idx, (data, targets) in enumerate(dataloader):
        # Déplacer sur GPU si disponible
        data, targets = data.to(device), targets.to(device)

        # Forward pass
        outputs = model(data)
        loss = criterion(outputs, targets)

        # Backward pass
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        total_loss += loss.item()

    return total_loss / len(dataloader)

# Utilisation
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

for epoch in range(num_epochs):
    train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device)
    print(f"Epoch {epoch+1}, Train Loss: {train_loss:.4f}")

Validation et métriques

Toujours valider sur un ensemble de validation séparé !

def validate(model, dataloader, criterion, device):
    model.eval()  # Mode évaluation
    total_loss = 0.0
    correct = 0
    total = 0

    with torch.no_grad():  # Désactiver autograd
        for data, targets in dataloader:
            data, targets = data.to(device), targets.to(device)

            # Forward pass
            outputs = model(data)
            loss = criterion(outputs, targets)

            total_loss += loss.item()

            # Accuracy
            _, predicted = outputs.max(1)
            correct += predicted.eq(targets).sum().item()
            total += targets.size(0)

    avg_loss = total_loss / len(dataloader)
    accuracy = 100.0 * correct / total

    return avg_loss, accuracy

# Utilisation
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)

for epoch in range(num_epochs):
    # Training
    train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device)

    # Validation
    val_loss, val_acc = validate(model, val_loader, criterion, device)

    print(f"Epoch {epoch+1}/{num_epochs}")
    print(f"  Train Loss: {train_loss:.4f}")
    print(f"  Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.2f}%")
⚠️ Warning
Toujours valider ! Ne jamais évaluer sur les données d’entraînement. Split vos données : 70-80% train, 10-15% validation, 10-15% test. La validation permet de détecter l’overfitting et de tuner les hyperparamètres.

Learning Rate Scheduling

Réduire le learning rate pendant l’entraînement améliore souvent les performances finales.

StepLR : Réduire tous les N epochs

from torch.optim.lr_scheduler import StepLR

optimizer = optim.Adam(model.parameters(), lr=0.1)

# Réduire LR par 0.1 tous les 10 epochs
scheduler = StepLR(optimizer, step_size=10, gamma=0.1)

for epoch in range(100):
    train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device)

    scheduler.step()  # Update learning rate

    current_lr = optimizer.param_groups[0]['lr']
    print(f"Epoch {epoch+1}, LR: {current_lr:.6f}, Loss: {train_loss:.4f}")

CosineAnnealingLR : Décroissance cosinus

from torch.optim.lr_scheduler import CosineAnnealingLR

optimizer = optim.Adam(model.parameters(), lr=0.1)
scheduler = CosineAnnealingLR(optimizer, T_max=100)  # 100 epochs

for epoch in range(100):
    train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device)
    scheduler.step()

    print(f"Epoch {epoch+1}, LR: {optimizer.param_groups[0]['lr']:.6f}")

ReduceLROnPlateau : Réduire si plateau

from torch.optim.lr_scheduler import ReduceLROnPlateau

optimizer = optim.Adam(model.parameters(), lr=0.01)

# Réduire LR si validation loss ne s'améliore pas pendant 5 epochs
scheduler = ReduceLROnPlateau(
    optimizer,
    mode='min',          # Minimiser la metric
    factor=0.5,          # Réduire par 0.5
    patience=5,          # Attendre 5 epochs
    verbose=True
)

for epoch in range(100):
    train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device)
    val_loss, val_acc = validate(model, val_loader, criterion, device)

    scheduler.step(val_loss)  # Basé sur validation loss

    print(f"Epoch {epoch+1}, Val Loss: {val_loss:.4f}")

OneCycleLR : Super-convergence

from torch.optim.lr_scheduler import OneCycleLR

optimizer = optim.Adam(model.parameters(), lr=0.01)

# OneCycleLR : augmente puis diminue le LR en 1 cycle
scheduler = OneCycleLR(
    optimizer,
    max_lr=0.1,                       # LR maximum
    steps_per_epoch=len(train_loader),
    epochs=num_epochs
)

for epoch in range(num_epochs):
    for batch_idx, (data, targets) in enumerate(train_loader):
        # ... training step ...

        scheduler.step()  # Appeler à chaque batch !
🔎 Tip
Scheduler recommandé : CosineAnnealingLR est un excellent choix par défaut (utilisé dans ResNet, Vision Transformers). ReduceLROnPlateau si vous voulez adapter automatiquement. OneCycleLR pour convergence ultra-rapide (nécessite tuning).

Sauvegarder et charger le modèle

Sauvegardez vos modèles entraînés pour les réutiliser !

Sauvegarder le Modèle Complet

# Sauvegarder tous les états
torch.save({
    'epoch': epoch,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'scheduler_state_dict': scheduler.state_dict(),
    'train_loss': train_loss,
    'val_loss': val_loss,
    'val_acc': val_acc
}, 'checkpoint.pth')

print("Modèle sauvegardé !")

Charger le modèle

# Créer le modèle (même architecture)
model = SimpleNet(input_size=784, hidden_size=128, output_size=10)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
scheduler = StepLR(optimizer, step_size=10, gamma=0.1)

# Charger les états
checkpoint = torch.load('checkpoint.pth')
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
scheduler.load_state_dict(checkpoint['scheduler_state_dict'])
epoch = checkpoint['epoch']

print(f"Modèle chargé depuis epoch {epoch}")

# Reprendre l'entraînement
model.train()
# ... continuer training ...

Sauvegarder seulement les poids (inférence)

# Pour inférence seulement
torch.save(model.state_dict(), 'model_weights.pth')

# Charger
model = SimpleNet(784, 128, 10)
model.load_state_dict(torch.load('model_weights.pth'))
model.eval()

Early Stopping

Arrêter l’entraînement si la validation ne s’améliore plus :

class EarlyStopping:
    def __init__(self, patience=5, min_delta=0.001):
        self.patience = patience
        self.min_delta = min_delta
        self.counter = 0
        self.best_loss = None
        self.early_stop = False

    def __call__(self, val_loss):
        if self.best_loss is None:
            self.best_loss = val_loss
        elif val_loss > self.best_loss - self.min_delta:
            self.counter += 1
            if self.counter >= self.patience:
                self.early_stop = True
        else:
            self.best_loss = val_loss
            self.counter = 0

# Utilisation
early_stopping = EarlyStopping(patience=10)

for epoch in range(100):
    train_loss = train_one_epoch(model, train_loader, criterion, optimizer, device)
    val_loss, val_acc = validate(model, val_loader, criterion, device)

    early_stopping(val_loss)

    if early_stopping.early_stop:
        print(f"Early stopping à epoch {epoch+1}")
        break

    print(f"Epoch {epoch+1}, Val Loss: {val_loss:.4f}")

Boucle d’entraînement complète

Voici un exemple complet intégrant tout :

import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import CosineAnnealingLR
from torch.utils.data import DataLoader

def train_model(model, train_loader, val_loader, num_epochs=50, device='cuda'):
    """
    Entraîne un modèle PyTorch de façon complète
    """
    # Setup
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
    scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs)

    # Tracking
    best_val_acc = 0.0
    history = {'train_loss': [], 'val_loss': [], 'val_acc': []}

    model = model.to(device)

    for epoch in range(num_epochs):
        # Training
        model.train()
        train_loss = 0.0

        for data, targets in train_loader:
            data, targets = data.to(device), targets.to(device)

            outputs = model(data)
            loss = criterion(outputs, targets)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            train_loss += loss.item()

        train_loss /= len(train_loader)

        # Validation
        model.eval()
        val_loss = 0.0
        correct = 0
        total = 0

        with torch.no_grad():
            for data, targets in val_loader:
                data, targets = data.to(device), targets.to(device)

                outputs = model(data)
                loss = criterion(outputs, targets)

                val_loss += loss.item()

                _, predicted = outputs.max(1)
                correct += predicted.eq(targets).sum().item()
                total += targets.size(0)

        val_loss /= len(val_loader)
        val_acc = 100.0 * correct / total

        # Learning rate scheduling
        scheduler.step()

        # Save best model
        if val_acc > best_val_acc:
            best_val_acc = val_acc
            torch.save({
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'val_acc': val_acc
            }, 'best_model.pth')

        # History
        history['train_loss'].append(train_loss)
        history['val_loss'].append(val_loss)
        history['val_acc'].append(val_acc)

        # Print progress
        print(f"Epoch [{epoch+1}/{num_epochs}]")
        print(f"  Train Loss: {train_loss:.4f}")
        print(f"  Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.2f}%")
        print(f"  LR: {optimizer.param_groups[0]['lr']:.6f}")
        print(f"  Best Val Acc: {best_val_acc:.2f}%")
        print()

    return history

# Utilisation
# model = YourModel()
# history = train_model(model, train_loader, val_loader, num_epochs=50)

Visualiser l’entraînement

import matplotlib.pyplot as plt

def plot_training_history(history):
    """
    Visualise les courbes d'entraînement
    """
    epochs = range(1, len(history['train_loss']) + 1)

    plt.figure(figsize=(12, 4))

    # Loss
    plt.subplot(1, 2, 1)
    plt.plot(epochs, history['train_loss'], 'b-', label='Train Loss')
    plt.plot(epochs, history['val_loss'], 'r-', label='Val Loss')
    plt.xlabel('Epoch')
    plt.ylabel('Loss')
    plt.title('Training and Validation Loss')
    plt.legend()
    plt.grid(True)

    # Accuracy
    plt.subplot(1, 2, 2)
    plt.plot(epochs, history['val_acc'], 'g-', label='Val Accuracy')
    plt.xlabel('Epoch')
    plt.ylabel('Accuracy (%)')
    plt.title('Validation Accuracy')
    plt.legend()
    plt.grid(True)

    plt.tight_layout()
    plt.savefig('training_history.png', dpi=150)
    plt.show()

# Utilisation
# plot_training_history(history)

Exercices pratiques

Training loop pour MNIST

Complétez cette boucle d’entraînement pour MNIST :

# Télécharger MNIST (nécessite torchvision)
# pip install torchvision

from torchvision import datasets, transforms

# Charger MNIST
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])

train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
val_dataset = datasets.MNIST('./data', train=False, transform=transform)

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)

# Créer un modèle simple
model = nn.Sequential(
    nn.Flatten(),
    nn.Linear(28*28, 128),
    nn.ReLU(),
    nn.Dropout(0.2),
    nn.Linear(128, 10)
)

# Entraîner pendant 10 epochs
# Objectif : atteindre >97% d'accuracy

Comparer optimizers

Comparez SGD, Adam et AdamW sur le même modèle/données. Quel optimizer converge le plus vite ? Lequel atteint la meilleure accuracy finale ?

Implémenter gradient clipping

Ajoutez du gradient clipping pour éviter les gradient explosions :

# À ajouter après loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()

Conclusion

Dans cet article, vous avez maîtrisé l’entraînement de modèles PyTorch :

  • ✅ Loss functions : CrossEntropy, BCE, MSE, et customs
  • ✅ Optimizers : SGD, Adam, AdamW
  • ✅ Training loop complète avec forward/backward/update
  • ✅ Validation et calcul de métriques (accuracy)
  • ✅ Learning rate scheduling (StepLR, Cosine, ReduceLROnPlateau)
  • ✅ Sauvegarder et charger des modèles
  • ✅ Early stopping et best model tracking

Prochaine étape : Dans le prochain article, nous allons apprendre à gérer les données efficacement avec Datasets et DataLoaders. Vous découvrirez comment charger des images, appliquer des transformations, et faire de la data augmentation.

Pour aller plus loin :

🔎 Tip
Exercice complet : Entraînez un classifieur MNIST de A à Z. Expérimentez avec différents learning rates (1e-4, 1e-3, 1e-2), optimizers (SGD, Adam), et schedulers. Visualisez les courbes de loss/accuracy. Sauvegardez le meilleur modèle et évaluez-le sur le test set. Objectif : >98% d’accuracy !