Apprentissage : la descente de gradient

Entraîner un réseau, ses pièges classiques, et le paradoxe de la généralisation

Auteur·rice

Augustin Chevallier

L’article précédent décrivait l’inférence : comment un réseau déjà entraîné produit une prédiction, ses paramètres \(\theta\) étant fixés. Reste la question centrale : comment trouve-t-on ces paramètres ? C’est l’objet de cet article, de la descente de gradient la plus simple jusqu’aux pièges classiques de l’entraînement — mauvais learning rate, points selle, surapprentissage — et à un paradoxe qui reste, aujourd’hui encore, partiellement incompris : pourquoi des réseaux surparamétrés généralisent-ils si bien ?

1 Rappels et objectif

Un modèle est un ensemble de fonctions \(\mathcal{F}\) de \(\mathbb{R}^p\) vers \(\mathbb{R}^q\), paramétré par \(\theta \in \mathbb{R}^k\) : \(\mathcal{F} = \{f(\cdot, \theta) \mid \theta \in \mathbb{R}^k\}\). On dispose de \(n\) données \(X\) et \(Y\), avec des variables explicatives \(x \in \mathbb{R}^p\) et une variable à expliquer \(y \in \mathbb{R}^q\).

Pour évaluer la qualité d’un choix de \(\theta\), on introduit une fonction de coût (ou loss) : une distance entre deux points de \(\mathbb{R}^q\), évaluée entre une prédiction et une vraie valeur, d’autant plus proche de 0 que les deux sont proches. Le coût quadratique en est l’exemple le plus courant :

\[loss(\theta) = \sum_{i=1}^n \|f(X_i) - Y_i\|^2\]

L’objectif de l’entraînement se résume alors simplement : minimiser cette fonction de coût sur nos données, c’est-à-dire trouver \(\theta\) qui rend \(loss(\theta)\) aussi petit que possible.

2 La descente de gradient

2.1 En une dimension

Le principe le plus simple pour minimiser une fonction \(f(x)\) de \(\mathbb{R}\) dans \(\mathbb{R}\) consiste à suivre la pente : partir d’un point initial \(x_0\), puis se déplacer, à chaque étape, dans la direction opposée à la dérivée :

\[x_{n+1} = x_n - \eta \frac{df}{dx}(x_n)\]

où \(\eta\) est le learning rate (taux d’apprentissage). Sous certaines conditions — notamment si \(\eta\) tend vers 0 — la suite \(x_n\) converge vers un minimum local. L’exemple ci-dessous illustre cette descente sur \(f(x) = x^2\), dont le minimum est en \(x=0\).

Afficher le code
import numpy as np
import matplotlib.pyplot as plt

def f(x):
    return x**2

def grad_f(x):
    return 2*x

x_init = 8
learning_rate = 0.1
epochs = 10

x_vals = [x_init]
f_vals = [f(x_init)]

x = x_init
for i in range(epochs):
    x = x - learning_rate * grad_f(x)
    x_vals.append(x)
    f_vals.append(f(x))

x_range = np.linspace(-10, 10, 400)
y_range = f(x_range)

plt.figure(figsize=(8, 6))
plt.plot(x_range, y_range, label="$f(x) = x^2$", color="blue")
plt.scatter(x_vals, f_vals, color="red", zorder=5)
plt.plot(x_vals, f_vals, color="red", linestyle="--", label="Descente de gradient", zorder=4)
for i, (x, y) in enumerate(zip(x_vals, f_vals)):
    plt.text(x, y, f"Step {i}", fontsize=8, verticalalignment='bottom')
plt.title("Illustration de la descente de gradient en 1D")
plt.xlabel("$x$")
plt.ylabel("$f(x)$")
plt.axhline(0, color="black", linewidth=0.5)
plt.axvline(0, color="black", linewidth=0.5)
plt.legend()
plt.grid(True)
plt.show()

2.2 En dimension quelconque

L’idée se généralise sans difficulté à une fonction \(f(x)\) de \(\mathbb{R}^d\) dans \(\mathbb{R}\) : la dérivée est remplacée par le gradient \(\nabla f\), qui pointe dans la direction de plus forte croissance de \(f\).

\[x_{n+1} = x_n - \eta \nabla f(x_n)\]

Sur une fonction à deux variables comportant quelques perturbations locales, la trajectoire suivie serpente autour des irrégularités avant de converger vers un minimum.

Afficher le code
import numpy as np
import matplotlib.pyplot as plt

def f(x, y):
    return x**2 + y**2 + 0.5 * np.sin(3 * x) + 0.1 * np.sin(3 * y)

def grad_f(x, y):
    df_dx = 2*x + 3/2 * np.cos(3 * x)
    df_dy = 2*y + 0.3 * np.cos(3 * y)
    return np.array([df_dx, df_dy])

learning_rate = 0.1
epochs = 20
x_init, y_init = 4, 4

x_vals, y_vals = [x_init], [y_init]

x, y = x_init, y_init
for i in range(epochs):
    grad = grad_f(x, y)
    x -= learning_rate * grad[0]
    y -= learning_rate * grad[1]
    x_vals.append(x)
    y_vals.append(y)

x_range = np.linspace(-5, 5, 400)
y_range = np.linspace(-5, 5, 400)
X, Y = np.meshgrid(x_range, y_range)
Z = f(X, Y)

plt.figure(figsize=(8, 6))
contours = plt.contour(X, Y, Z, levels=30, cmap='viridis')
plt.clabel(contours)
plt.plot(x_vals, y_vals, 'ro-', markersize=3, label='Gradient Descent')
for i in range(1, len(x_vals), 4):
    plt.arrow(x_vals[i-1], y_vals[i-1], x_vals[i]-x_vals[i-1], y_vals[i]-y_vals[i-1],
              head_width=0.15, head_length=0.15, fc='red', ec='red')
plt.title("Descente de gradient sur une fonction quadratique perturbée")
plt.xlabel('$x$')
plt.ylabel('$y$')
plt.axhline(0, color='black', linewidth=0.5)
plt.axvline(0, color='black', linewidth=0.5)
plt.grid(True)
plt.legend()
plt.show()

2.3 La descente de gradient stochastique

En apprentissage, la fonction à minimiser est une somme sur toutes les données d’entraînement — potentiellement des millions d’exemples. Calculer \(\nabla f\) exactement à chaque étape devient alors prohibitivement coûteux.

L’idée du gradient stochastique (SGD) est de s’en passer : on suppose disposer d’un estimateur \(G\) de \(\nabla f\), sans biais (\(\mathbb{E}[G(x)] = \nabla f(x)\)), mais bien moins coûteux à calculer. La mise à jour des paramètres devient :

\[x_{n+1} = x_n - \eta\, G(x_n)\]

En pratique, \(G\) est simplement le gradient calculé sur un petit sous-ensemble aléatoire des données — un batch — plutôt que sur l’ensemble complet. La trajectoire qui en résulte est plus bruitée que la descente de gradient classique, comme le montre l’exemple ci-dessous, sur la même fonction que précédemment, mais avec un gradient volontairement perturbé par du bruit.

Afficher le code
import numpy as np
import matplotlib.pyplot as plt

def f(x, y):
    return x**2 + y**2 + 0.5 * np.sin(3 * x) + 0.1 * np.sin(3 * y)

def grad_f_stochastic(x, y):
    df_dx = 2 * x + 3/2 * np.cos(3 * x) + np.random.normal(scale=2)
    df_dy = 2 * y + 0.3 * np.cos(3 * y) + np.random.normal(scale=2)
    return np.array([df_dx, df_dy])

learning_rate = 0.1
epochs = 20
x_init, y_init = 4, 4

x_vals, y_vals = [x_init], [y_init]

x, y = x_init, y_init
for i in range(epochs):
    grad = grad_f_stochastic(x, y)
    x -= learning_rate * grad[0]
    y -= learning_rate * grad[1]
    x_vals.append(x)
    y_vals.append(y)

x_range = np.linspace(-5, 5, 400)
y_range = np.linspace(-5, 5, 400)
X, Y = np.meshgrid(x_range, y_range)
Z = f(X, Y)

plt.figure(figsize=(8, 6))
contours = plt.contour(X, Y, Z, levels=30, cmap='viridis')
plt.clabel(contours)
plt.plot(x_vals, y_vals, 'ro-', markersize=3, label='SGD Path')
for i in range(1, len(x_vals), 2):
    plt.arrow(x_vals[i-1], y_vals[i-1], x_vals[i]-x_vals[i-1], y_vals[i]-y_vals[i-1],
              head_width=0.15, head_length=0.15, fc='red', ec='red')
plt.title("Descente de gradient stochastique (SGD)")
plt.xlabel('$x$')
plt.ylabel('$y$')
plt.axhline(0, color='black', linewidth=0.5)
plt.axvline(0, color='black', linewidth=0.5)
plt.grid(True)
plt.legend()
plt.show()

2.4 SGD en pratique

Concrètement, pour un modèle \(f_\theta\), la fonction de coût à minimiser somme le coût sur toutes les données \((x_i, y_i)\) du jeu d’entraînement :

\[l(\theta) = \sum_i \text{distance}(f_\theta(x_i), y_i)\]

Le gradient stochastique découpe ce dataset en batches \(B_1, \dots, B_k\), en choisit un au hasard, et évalue la fonction de coût sur ce seul batch. Tant que les batches ont la même taille, cet estimateur reste sans biais. Deux tailles de batch s’opposent en pratique : des petits batches sont rapides à calculer mais donnent un estimateur bruité, des grands batches sont plus coûteux mais réduisent la variance de l’estimation.

3 La différentiation automatique

Reste une question pratique : comment calculer le gradient de la fonction de coût pour un réseau de neurones, potentiellement composé de millions de paramètres ? Deux approches naïves échouent rapidement. Les différences finies sont beaucoup trop lentes. Un calcul à la main sur papier est bien trop compliqué, et il faudrait tout refaire dès que le modèle change.

La solution est la différentiation automatique, qu’implémente PyTorch : une méthode qui calcule automatiquement les dérivées d’expressions mathématiques — ou, de façon équivalente, de programmes informatiques.

3.1 Comment ça fonctionne

PyTorch observe les opérations mathématiques effectuées lors de l’évaluation du modèle — additions, multiplications, exponentielles, fonctions trigonométriques — et les enregistre au fur et à mesure. Le modèle est ainsi vu comme une composition de fonctions mathématiques usuelles, dont PyTorch connaît les dérivées. Il suffit alors d’appliquer la règle de dérivation des fonctions composées pour évaluer le gradient de bout en bout. (C’est une légère simplification : en réalité, PyTorch construit un arbre décrivant l’expression mathématique complète du modèle.)

Formellement, pour \(f = f_1 \circ f_2 \circ f_3\), la différentielle de \(f\) en \(x\) s’obtient en posant \(A_1 = J_{f_1}(f_2 \circ f_3(x))\), \(A_2 = J_{f_2}(f_3(x))\) et \(A_3 = J_{f_3}(x)\) ; le calcul de la différentielle donne alors \(J_f(x) = A_1 A_2 A_3\).

3.2 Un choix qui compte : l’ordre du produit matriciel

Ce produit de matrices peut se calculer de deux façons, à des coûts très différents. En mode forward, \(J_f(x) = A_1(A_2 A_3)\) : en posant \(B = A_2 A_3\), le calcul de \(B\) coûte \(O(n^3)\), puis celui de \(A_1 B\) coûte \(O(n^2)\) — un coût total en \(O(n^3)\). En mode backward, \(J_f(x) = (A_1 A_2) A_3\) : en posant \(C = A_1 A_2\), chacun des deux produits coûte \(O(n^2)\) — un coût total en \(O(n^2)\), nettement plus favorable ici.

Plus généralement, le mode forward est préférable quand l’espace de départ est plus petit que l’espace d’arrivée ; le mode backward, quand c’est l’inverse. Pour l’apprentissage, l’espace d’arrivée de la fonction de coût est \(\mathbb{R}\) — un scalaire — ce qui rend le mode backward presque toujours optimal. C’est la raison pour laquelle on parle de rétropropagation (backpropagation) plutôt que de propagation avant pour entraîner un réseau de neurones.

3.3 Un exemple avec PyTorch

En PyTorch, il suffit d’indiquer qu’on souhaite calculer des gradients par rapport à une variable, avec requires_grad=True, puis d’appeler .backward() sur le résultat d’un calcul pour obtenir la différentiation automatique. Le gradient est alors stocké dans l’attribut .grad de la variable d’origine.

Afficher le code
import torch

x = torch.tensor(3.0, requires_grad=True)

y = x**2 + x
print("y =", y.item())

y.backward()

print(f"dy/dx = {x.grad.item()} en x =", x.item())
y = 12.0
dy/dx = 7.0 en x = 3.0

Un point mérite d’être signalé : PyTorch accumule les gradients. Si l’on évalue le gradient de deux expressions dépendant du même tenseur \(x\), les deux contributions s’additionnent — un comportement très utile pour certains usages avancés, mais qui impose de ne pas oublier de remettre les gradients à zéro entre deux calculs indépendants.

4 Apprendre avec PyTorch

4.1 Charger les données

PyTorch distingue deux concepts complémentaires : le Dataset, qui charge les données à la demande, et le DataLoader, qui permet d’itérer sur ce dataset et de constituer des batches — l’ingrédient nécessaire à la descente de gradient stochastique.

Le plus simple des datasets se construit directement depuis un tenseur, avec TensorDataset : __len__() donne accès à la taille du dataset, __getitem__(i) au \(i\)-ème élément.

Afficher le code
from torch.utils.data import TensorDataset

data = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
labels = torch.tensor([[0.0], [0.0], [1.0], [1.0]])

dataset = TensorDataset(data, labels)

print("length:", dataset.__len__())
print("elem 0:", dataset.__getitem__(0))
length: 4
elem 0: (tensor([1.]), tensor([0.]))

Pour des données plus complexes — chargées à la demande depuis des fichiers plutôt que tenues entièrement en mémoire — on définit sa propre classe, qui doit implémenter trois méthodes : __init__, pour l’initialisation (par exemple avec le répertoire où se trouvent les données), __len__, qui retourne la taille du dataset, et __getitem__, qui retourne une donnée à un index donné.

class CustomDataset(Dataset):
    def __init__(self, some_args, transform=None, target_transform=None):
        # initialiser ce dont on a besoin ici
        ...

    def __len__(self):
        # retourner la taille du dataset
        ...

    def __getitem__(self, idx):
        # charger la donnée à l'index idx
        ...
        return data, label

Ce n’est utile que si le dataset ne tient pas confortablement en mémoire ; sinon, TensorDataset suffit largement.

Une fois le dataset défini, le DataLoader s’occupe de créer un itérateur qui parcourt le dataset en batches, avec un batch_size (nombre d’échantillons par batch) et une option shuffle pour mélanger les données à chaque parcours.

Afficher le code
from torch.utils.data import DataLoader, TensorDataset

data = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
labels = torch.tensor([[0.0], [0.0], [1.0], [1.0]])
dataset = TensorDataset(data, labels)

dataloader = DataLoader(dataset, batch_size=2, shuffle=True)

for batch in dataloader:
    print("data:", batch[0], "labels:", batch[1])
data: tensor([[1.],
        [3.]]) labels: tensor([[0.],
        [1.]])
data: tensor([[2.],
        [4.]]) labels: tensor([[0.],
        [1.]])

4.2 Un exemple complet : FashionMNIST

Reprenons le modèle et les données de l’article sur l’inférence : un classifieur à deux couches cachées de 512 paramètres, sur le jeu FashionMNIST.

Afficher le code
from torch.utils.data import Dataset, DataLoader
from torchvision import datasets
from torchvision.transforms import ToTensor
import torch.nn as nn
import torch.nn.functional as F

training_data = datasets.FashionMNIST(root="data", train=True, download=True, transform=ToTensor())
test_data = datasets.FashionMNIST(root="data", train=False, download=True, transform=ToTensor())

labels_map = {
    0: "T-Shirt", 1: "Trouser", 2: "Pullover", 3: "Dress", 4: "Coat",
    5: "Sandal", 6: "Shirt", 7: "Sneaker", 8: "Bag", 9: "Ankle Boot",
}


class SimpleNN(nn.Module):
    def __init__(self):
        super(SimpleNN, self).__init__()
        self.flatten = nn.Flatten()
        self.w1 = nn.Parameter(torch.randn(28*28, 512) * 0.01)
        self.b1 = nn.Parameter(torch.zeros(512))
        self.w2 = nn.Parameter(torch.randn(512, 512) * 0.01)
        self.b2 = nn.Parameter(torch.zeros(512))
        self.w3 = nn.Parameter(torch.randn(512, 10) * 0.01)
        self.b3 = nn.Parameter(torch.zeros(10))

    def forward(self, x):
        x = self.flatten(x)
        x = F.relu(x.matmul(self.w1) + self.b1)
        x = F.relu(x.matmul(self.w2) + self.b2)
        x = x.matmul(self.w3) + self.b3
        return x


model = SimpleNN()
loss_function = torch.nn.CrossEntropyLoss()

Pour évaluer le coût sur un batch de données, il suffit d’appeler le modèle, puis de comparer sa sortie aux vraies étiquettes via la fonction de coût :

Afficher le code
dataloader = DataLoader(training_data, batch_size=2, shuffle=True)

for batch_data, batch_labels in dataloader:
    outputs = model(batch_data)
    loss = loss_function(outputs, batch_labels)
    print(f'Loss: {loss.item()}')
    break
Loss: 2.3001656532287598

Reste à boucler cette évaluation sur l’ensemble des données, en mettant à jour les paramètres à chaque étape. Trois façons d’écrire cette mise à jour, de la plus explicite à la plus idiomatique :

La plus explicite met à jour chaque paramètre à la main, après avoir calculé le gradient et remis à zéro celui du pas précédent :

model.zero_grad()
outputs = model(batch_data)
loss = loss_function(outputs, batch_labels)
loss.backward()
with torch.no_grad():
    for param in model.parameters():
        param -= learning_rate * param.grad

On peut ensuite boucler cette mise à jour sur plusieurs epochs — un epoch correspondant à un passage complet à travers l’ensemble du jeu d’entraînement — en parcourant le DataLoader par batches à chaque epoch.

En pratique, on ne réécrit presque jamais la mise à jour des poids à la main : PyTorch fournit des optimizers, comme torch.optim.SGD, qui encapsulent cette logique. optimizer.zero_grad() remet le gradient à zéro, optimizer.step() met à jour les paramètres du modèle. C’est cette forme, la plus idiomatique, qu’on utilise ci-dessous pour entraîner réellement le modèle, sur quelques epochs.

Afficher le code
train_loader = DataLoader(dataset=training_data, batch_size=256, shuffle=True)
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

num_epochs = 3
for epoch in range(num_epochs):
    for batch_data, batch_labels in train_loader:
        optimizer.zero_grad()
        outputs = model(batch_data)
        loss = loss_function(outputs, batch_labels)
        loss.backward()
        optimizer.step()
    print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')
Epoch [1/3], Loss: 0.9575
Epoch [2/3], Loss: 0.7514
Epoch [3/3], Loss: 0.5181

Le modèle vient d’être entraîné, avec l’API la plus courante de PyTorch : un DataLoader pour parcourir les données par batches, et un optimizer pour gérer la mise à jour des poids. C’est exactement ce mécanisme, décliné avec des optimizers plus sophistiqués (Adam, en particulier), qui entraîne en pratique l’immense majorité des réseaux de neurones.

5 Problèmes classiques de l’entraînement

Entraîner un réseau de neurones ne se résume pas à lancer une descente de gradient et attendre. Plusieurs pièges classiques peuvent faire échouer, ou simplement ralentir, l’apprentissage.

5.1 Un mauvais choix de learning rate

Le taux d’apprentissage est un hyperparamètre clé, qui contrôle la vitesse à laquelle le modèle ajuste ses poids. Un taux trop élevé peut entraîner des oscillations autour du minimum, voire empêcher totalement la convergence. Un taux trop faible ralentit considérablement l’entraînement, au risque de piéger le modèle dans un minimum local ou de le voir stagner. L’exemple ci-dessous entraîne le même petit réseau de régression sur trois taux d’apprentissage différents, pour comparer leur comportement.

Afficher le code
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt

x = torch.unsqueeze(torch.linspace(-5, 5, 100), dim=1)
y = x.pow(2) + 0.2 * torch.rand(x.size())


class SimpleNet(nn.Module):
    def __init__(self):
        super(SimpleNet, self).__init__()
        self.hidden = nn.Linear(1, 10)
        self.predict = nn.Linear(10, 1)

    def forward(self, x):
        x = torch.relu(self.hidden(x))
        return self.predict(x)


criterion = nn.MSELoss()
results = {}

for name, lr in [("High LR (0.1)", 0.1), ("Low LR (0.0001)", 0.0001), ("Optimal LR (0.01)", 0.01)]:
    torch.manual_seed(0)
    model = SimpleNet()
    optimizer = optim.SGD(model.parameters(), lr=lr)
    losses = []
    for epoch in range(50):
        prediction = model(x)
        loss = criterion(prediction, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        losses.append(loss.item())
    results[name] = losses

plt.figure(figsize=(8, 5))
for name, losses in results.items():
    plt.plot(losses, label=name)
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.yscale("log")
plt.title("Comparaison des taux d'apprentissage")
plt.legend()
plt.show()

5.2 Les learning rate schedulers

Plutôt que de fixer le learning rate une fois pour toutes, les schedulers l’ajustent automatiquement au cours de l’entraînement. Deux grandes familles existent : réduire le learning rate au fil du temps pour converger proprement vers un minimum (par exemple StepLR), ou le réduire puis le remonter périodiquement, pour aider le modèle à sortir des minimums locaux (par exemple CosineAnnealingWarmRestarts).

Afficher le code
import torch.optim as optim

model = nn.Linear(10, 1)
optimizer = optim.SGD(model.parameters(), lr=0.1)

epochs = 100
learning_rates_step = []
learning_rates_cosine_restart = []

scheduler_step = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
for epoch in range(epochs):
    learning_rates_step.append(optimizer.param_groups[0]["lr"])
    scheduler_step.step()

optimizer = optim.SGD(model.parameters(), lr=0.1)
scheduler_cosine_restart = optim.lr_scheduler.CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2)
for epoch in range(epochs):
    learning_rates_cosine_restart.append(optimizer.param_groups[0]["lr"])
    scheduler_cosine_restart.step(epoch)

plt.figure(figsize=(8, 5))
plt.plot(range(epochs), learning_rates_step, label='StepLR')
plt.plot(range(epochs), learning_rates_cosine_restart, label='CosineAnnealingWarmRestarts')
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')
plt.title('Deux stratégies de learning rate scheduling')
plt.legend()
plt.grid(True)
plt.show()
/tmp/ipykernel_237001/515352107.py:13: UserWarning: Detected call of `lr_scheduler.step()` before `optimizer.step()`. In PyTorch 1.1.0 and later, you should call them in the opposite order: `optimizer.step()` before `lr_scheduler.step()`.  Failure to do this will result in PyTorch skipping the first value of the learning rate schedule. See more details at https://pytorch.org/docs/stable/optim.html#how-to-adjust-learning-rate
  scheduler_step.step()

5.3 Les points selle

Un point selle est un point où le gradient est nul, mais qui n’est ni un minimum ni un maximum — à ne pas confondre avec un minimum local, où le gradient est également nul mais qui représente bien un minimum de la fonction de perte. Le problème : la descente de gradient peut s’arrêter, ou fortement ralentir, au voisinage d’un point selle, puisque le gradient y est proche de zéro dans certaines directions.

Afficher le code
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

x = np.linspace(-2, 2, 100)
y = np.linspace(-2, 2, 100)
X, Y = np.meshgrid(x, y)
Z = X**2 - Y**2  # Fonction en forme de selle

fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(X, Y, Z, cmap='coolwarm')
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_zlabel('Z')
plt.title("Illustration d'un point selle")
plt.show()

La descente de gradient classique reste facilement bloquée à proximité d’un point selle. Une des solutions les plus efficaces consiste à changer d’optimizer : Adam, par exemple, utilise des moments pour adapter ses pas de gradient, ce qui l’aide à échapper aux points selle et à converger plus rapidement. La comparaison ci-dessous illustre ce contraste : partant du même point, tout près d’un point selle, SGD y reste bloqué de longues itérations tandis qu’Adam s’en échappe rapidement.

Afficher le code
import torch
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

def saddle_function(x, y):
    return x**2 - y**2 + 0.1 * (x**4 + y**4)

x_vals = np.linspace(-2.5, 2.5, 100)
y_vals = np.linspace(-2.5, 2.5, 100)
X, Y = np.meshgrid(x_vals, y_vals)
Z = X**2 - Y**2 + 0.1 * (X**4 + Y**4)

def run(optimizer_cls, **kwargs):
    x = torch.tensor(-1.5, requires_grad=True)
    y = torch.tensor(-0.0001, requires_grad=True)
    optimizer = optimizer_cls([x, y], **kwargs)
    trajectory = [(x.item(), y.item(), saddle_function(x, y).item())]
    for _ in range(50):
        optimizer.zero_grad()
        loss = saddle_function(x, y)
        loss.backward()
        optimizer.step()
        trajectory.append((x.item(), y.item(), saddle_function(x, y).item()))
    return trajectory

trajectory_sgd = run(optim.SGD, lr=0.1)
trajectory_adam = run(optim.Adam, lr=0.1)

fig = plt.figure(figsize=(14, 6))

ax1 = fig.add_subplot(121, projection='3d')
ax1.plot_surface(X, Y, Z, cmap='coolwarm', alpha=0.6)
sgd_x, sgd_y, sgd_z = zip(*trajectory_sgd)
adam_x, adam_y, adam_z = zip(*trajectory_adam)
ax1.plot(sgd_x, sgd_y, sgd_z, marker='o', color='blue', label='SGD')
ax1.plot(adam_x, adam_y, adam_z, marker='o', color='orange', label='Adam')
ax1.set_title("SGD vs Adam, 50 pas")
ax1.legend()

ax2 = fig.add_subplot(122)
contour = ax2.contourf(X, Y, Z, levels=50, cmap='coolwarm', alpha=0.7)
plt.colorbar(contour, ax=ax2, label='Loss')
ax2.plot(sgd_x, sgd_y, marker='o', color='blue', label='SGD')
ax2.plot(adam_x, adam_y, marker='o', color='orange', label='Adam')
ax2.set_title("SGD vs Adam, vue de dessus")
ax2.legend()

plt.tight_layout()
plt.show()

5.4 Le surapprentissage

Un modèle trop flexible risque d’apprendre le bruit des données d’entraînement plutôt que la structure sous-jacente : c’est le surapprentissage. Deux stratégies classiques permettent de le limiter.

L’early stopping consiste à arrêter l’entraînement dès que la performance sur un jeu de validation cesse de s’améliorer, plutôt que de poursuivre jusqu’à convergence complète sur l’entraînement. On tolère généralement une stagnation pendant un nombre fixé d’epochs — le critère de patience — avant d’arrêter réellement. La démonstration ci-dessous entraîne un réseau volontairement trop complexe pour la tâche, avec et sans early stopping : sans lui, l’erreur de test finit par remonter alors même que l’erreur d’entraînement continue de baisser — la signature classique du surapprentissage.

Afficher le code
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split

x = torch.linspace(-1, 1, 200).reshape(-1, 1)
y = x**3 + 0.2 * torch.sin(5 * x) + 1 * torch.randn(x.size())

x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=42)


class ComplexNet(nn.Module):
    def __init__(self):
        super(ComplexNet, self).__init__()
        self.fc1 = nn.Linear(1, 128)
        self.fc2 = nn.Linear(128, 128)
        self.fc3 = nn.Linear(128, 128)
        self.fc4 = nn.Linear(128, 1)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        x = torch.relu(self.fc3(x))
        return self.fc4(x)


def train_model(model, optimizer, loss_fn, early_stopping=True, patience=10, max_epochs=1000):
    best_loss = float('inf')
    patience_counter = 0
    train_losses, test_losses = [], []
    for epoch in range(max_epochs):
        optimizer.zero_grad()
        loss_train = loss_fn(model(x_train), y_train)
        loss_train.backward()
        optimizer.step()
        with torch.no_grad():
            loss_test = loss_fn(model(x_test), y_test)
        train_losses.append(loss_train.item())
        test_losses.append(loss_test.item())
        if early_stopping:
            if loss_test < best_loss:
                best_loss, patience_counter = loss_test, 0
            else:
                patience_counter += 1
            if patience_counter >= patience:
                break
    return train_losses, test_losses


loss_fn = nn.MSELoss()
model_early = ComplexNet()
train_early, test_early = train_model(model_early, optim.SGD(model_early.parameters(), lr=0.05), loss_fn, early_stopping=True)

model_no_early = ComplexNet()
train_no_early, test_no_early = train_model(model_no_early, optim.SGD(model_no_early.parameters(), lr=0.05), loss_fn, early_stopping=False)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
ax1.plot(train_no_early, label='Sans early stopping', linestyle='--')
ax1.plot(train_early, label='Avec early stopping')
ax1.set_xlabel("Epochs"); ax1.set_ylabel("Training Loss"); ax1.legend(); ax1.set_title("Perte d'entraînement")

ax2.plot(test_no_early, label='Sans early stopping', linestyle='--')
ax2.plot(test_early, label='Avec early stopping')
ax2.set_xlabel("Epochs"); ax2.set_ylabel("Test Loss"); ax2.legend(); ax2.set_title("Perte de test")

plt.tight_layout()
plt.show()

La seconde stratégie, la régularisation, ajoute une pénalité à la fonction de coût pour décourager les poids trop élevés, ce qui réduit la complexité effective du modèle et favorise la généralisation. Les deux formes les plus courantes sont la régularisation L2 (Ridge), de pénalité \(\lambda \sum w^2\), et la régularisation L1 (Lasso), de pénalité \(\lambda \sum |w|\) ; le dropout, qui désactive aléatoirement des neurones pendant l’entraînement, en est une variante différente mais poursuit le même but. Toutes diminuent la variance du modèle et limitent le surajustement aux données d’entraînement. L’exemple suivant compare, sur un tout petit jeu de données particulièrement propice au surapprentissage, un modèle entraîné avec et sans régularisation L2 (weight_decay).

Afficher le code
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split

x = torch.linspace(-1, 1, 20).reshape(-1, 1)
y = x**3 + 0.2 * torch.sin(5 * x) + 1 * torch.randn(x.size())

x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=42)


class ComplexNet(nn.Module):
    def __init__(self):
        super(ComplexNet, self).__init__()
        self.fc1 = nn.Linear(1, 128)
        self.fc2 = nn.Linear(128, 128)
        self.fc3 = nn.Linear(128, 128)
        self.fc4 = nn.Linear(128, 1)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        x = torch.relu(self.fc3(x))
        return self.fc4(x)


def train(weight_decay, epochs=500):
    model = ComplexNet()
    optimizer = optim.SGD(model.parameters(), lr=0.02, weight_decay=weight_decay)
    scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=200, gamma=0.5)
    loss_fn = nn.MSELoss()
    train_losses, test_losses = [], []
    for epoch in range(epochs):
        optimizer.zero_grad()
        loss_train = loss_fn(model(x_train), y_train)
        loss_train.backward()
        optimizer.step()
        scheduler.step()
        train_losses.append(loss_train.item())
        with torch.no_grad():
            test_losses.append(loss_fn(model(x_test), y_test).item())
    return train_losses, test_losses


train_no_reg, test_no_reg = train(weight_decay=0.0)
train_with_reg, test_with_reg = train(weight_decay=0.1)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
ax1.plot(train_no_reg, label='Sans régularisation')
ax1.plot(train_with_reg, label='Avec régularisation L2', linestyle='--')
ax1.set_xlabel("Epochs"); ax1.set_ylabel("Training Loss"); ax1.legend(); ax1.set_title("Perte d'entraînement")

ax2.plot(test_no_reg, label='Sans régularisation')
ax2.plot(test_with_reg, label='Avec régularisation L2', linestyle='--')
ax2.set_xlabel("Epochs"); ax2.set_ylabel("Test Loss"); ax2.legend(); ax2.set_title("Perte de test")

plt.tight_layout()
plt.show()

6 Généralisation : un paradoxe non résolu

6.1 Le paradoxe

La théorie classique de l’apprentissage statistique — dimension de VC, complexité de Rademacher — est formelle : plus un modèle a de paramètres par rapport au nombre d’exemples, plus il risque le surapprentissage, presque garanti au-delà d’un certain seuil. Or les réseaux de neurones modernes comptent souvent des paramètres en bien plus grand nombre que les exemples sur lesquels ils sont entraînés — le régime surparamétré évoqué dans l’article sur l’inférence. Et pourtant, ils généralisent remarquablement bien en pratique. La théorie classique prédit l’échec ; l’expérience dit le contraire.

6.2 Zhang et al. (ICLR 2017)

Une expérience, devenue célèbre, illustre à quel point ce paradoxe est profond. Dans « Understanding deep learning requires rethinking generalization », Zhang et ses coauteurs entraînent un CNN sur des images associées à des labels aléatoires — un bruit pur, sans aucune structure. Le réseau atteint 0 % d’erreur d’entraînement : il mémorise parfaitement ce bruit. Or ce même réseau, avec les mêmes hyperparamètres, généralise très bien sur de vraies données. La conclusion est sans appel : la capacité du réseau suffit largement à tout mémoriser, ce qui signifie que les mesures classiques de capacité ne peuvent pas, à elles seules, expliquer pourquoi ce même réseau généralise bien sur de vraies données.

6.3 Double descent

L’intuition classique du compromis biais-variance prédit une courbe en U : augmenter la capacité d’un modèle aide d’abord, puis finit par nuire. L’observation empirique du phénomène de double descent vient nuancer cette intuition : au-delà du point d’interpolation — le seuil de capacité à partir duquel le modèle peut ajuster parfaitement les données d’entraînement — l’erreur de test, après avoir culminé, rebaisse. On distingue ainsi deux régimes séparés par un pic d’erreur : un régime « classique », sous-paramétré, et un régime « moderne », surparamétré. Le phénomène n’est pas propre aux réseaux de neurones profonds : on l’observe jusque sur de simples modèles linéaires à features aléatoires, comme l’illustre la démonstration ci-dessous.

Afficher le code
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(0)

n_train, n_test, d = 40, 200, 20
X_train = rng.normal(size=(n_train, d))
X_test = rng.normal(size=(n_test, d))
noise_std = 0.5

max_p = 500
W = rng.normal(size=(d, max_p)) / np.sqrt(d)

def features(X, p):
    return np.cos(X @ W[:, :p])  # features aléatoires

# La vérité est elle-même une combinaison d'un petit nombre (d_true) de ces
# mêmes features aléatoires : ça donne un vrai "régime classique" où augmenter
# la capacité aide d'abord, avant le pic au seuil d'interpolation.
d_true = 8
beta_true = rng.normal(size=d_true)
y_train = features(X_train, d_true) @ beta_true + noise_std * rng.normal(size=n_train)
y_test = features(X_test, d_true) @ beta_true + noise_std * rng.normal(size=n_test)

p_list = [1, 2, 4, 6, 8, 10, 15, 20, 25, 30, 35, 38, 39, 40, 41, 42, 45, 50, 60, 80, 120, 200, 300, 500]
train_errors, test_errors = [], []
for p in p_list:
    Ztr, Zte = features(X_train, p), features(X_test, p)
    beta_hat, *_ = np.linalg.lstsq(Ztr, y_train, rcond=None)  # solution de norme minimale si p > n_train
    train_errors.append(np.mean((Ztr @ beta_hat - y_train) ** 2))
    test_errors.append(np.mean((Zte @ beta_hat - y_test) ** 2))

plt.figure(figsize=(8, 4))
plt.plot(p_list, train_errors, marker="o", label="Erreur d'entraînement")
plt.plot(p_list, test_errors, marker="o", label="Erreur de test")
plt.axvline(n_train, color="grey", linestyle="--", label="Seuil d'interpolation ($p=n$)")
plt.axvline(d_true, color="green", linestyle=":", label="Complexité réelle ($p=d_{true}$)")
plt.xscale("log")
plt.yscale("log")
plt.xlabel("Nombre de features (capacité du modèle)")
plt.ylabel("Erreur quadratique moyenne")
plt.title("Double descent : régression sur features aléatoires")
plt.legend()
plt.grid(True)
plt.show()

Le régime classique est bien visible sur la partie gauche de la courbe : jusqu’à \(p \approx d_{true}\), la vraie complexité du problème, ajouter des features aide, et l’erreur de test baisse. Au-delà, elle remonte, avec un pic exactement au point d’interpolation (\(p \approx n\)) ; passé ce point, ajouter encore davantage de paramètres… aide de nouveau. Les deux descentes sont bien là : c’est très exactement le phénomène de double descent.

6.4 Restons honnêtes

Il n’existe pas aujourd’hui de théorie complète expliquant pourquoi les réseaux surparamétrés généralisent aussi bien. Plusieurs pistes sont explorées — marge de décision, platitude des minima trouvés, biais implicite de la descente de gradient stochastique, noyau tangent neuronal (NTK) — mais aucune ne fait consensus. Ce que l’expérience montre reste, pour l’instant, en avance sur ce que la théorie parvient à expliquer.