Apprentissage

Author

Augustin Chevallier

1 Apprentissage: descente de gradient

1.1 Rappels: notations

Un modèle est un ensemble de fonctions \(\mathcal{F}\) de \(\mathbb{R}^p \rightarrow \mathbb{R}^q\)

  • \(\theta \in \mathbb{R}^k\)
  • \(\mathcal{F}\) est paramétré par \(\theta\): \(\mathcal{F} = \{f(.,\theta) | \theta \in \mathbb{R}^k\}\)

Données

  • variables explicatives: \(x = (x_1,...,x_p) \in \mathbb{R}^p\)
  • variable(s) à expliquer: \(y \in \mathbb{R}^q\)
  • On dispose de \(n\) données \(X\) et \(Y\)

1.2 Fonction de coût

Fonction de coût/loss :

  • distance entre 2 points de \(\mathbb{R}^q\)
  • évaluée entre une prédiction et une vraie valeur
  • plus elle est proche de 0, plus les valeurs sont proches

Exemple: coût quadratique:

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

1.3 Objectif

Minimiser cette fonction de coût sur nos données

1.4 Descente de gradient 1D

  • Problème : Minimiser une fonction \(f(x)\) de \(\mathbb{R}\) dans \(\mathbb{R}\).
  • Exemple : \(f(x) = x^2\), dont le minimum est à \(x = 0\).
  • Initialisation : \(x_0\)
  • Mise à jour des paramètres : \[ x_{n+1} = x_{n} - \eta \frac{df}{dx}(x_n) \]\(\eta\) est le learning rate.
  • Convergence : \(x_n\) converge vers un minimum local sous certaines conditions (dont \(\eta \rightarrow 0\))
import numpy as np
import matplotlib.pyplot as plt

# Définition de la fonction et de son gradient
def f(x):
    return x**2

def grad_f(x):
    return 2*x

# Paramètres de la descente de gradient
x_init = 8  # Point de départ
learning_rate = 0.1  # Taux d'apprentissage
epochs = 10  # Nombre d'itérations

# Stocker les valeurs pour l'illustration
x_vals = [x_init]
f_vals = [f(x_init)]

# Descente de gradient
x = x_init
for i in range(epochs):
    x = x - learning_rate * grad_f(x)  # Mise à jour du paramètre
    x_vals.append(x)  # Sauvegarder la valeur de x
    f_vals.append(f(x))  # Sauvegarder la valeur de f(x)

# Générer des points pour dessiner la fonction
x_range = np.linspace(-10, 10, 400)
y_range = f(x_range)

# Tracer la fonction et les étapes de la descente de gradient
plt.figure(figsize=(8, 6))
plt.plot(x_range, y_range, label="$f(x) = x^2$", color="blue")

# Visualisation des points de la descente de gradient
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)

# Annotations pour montrer les étapes
for i, (x, y) in enumerate(zip(x_vals, f_vals)):
    plt.text(x, y, f"Step {i}", fontsize=8, verticalalignment='bottom')

# Mise en forme de la figure
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()

1.5 Descente de gradient

  • Problème : Minimiser une fonction \(f(x)\) de \(\mathbb{R}^d\) dans \(\mathbb{R}\).
  • Exemple : \(f(x) = \|x\|^2\), dont le minimum est à \(x = 0\).
  • Initialisation : \(x_0 \in \mathbb{R}^d\)
  • Mise à jour des paramètres : \[ x_{n+1} = x_{n} - \eta \nabla f(x_n) \]\(\eta\) est le learning rate.
import numpy as np
import matplotlib.pyplot as plt

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

# Gradient of the perturbed quadratic function
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])

# Parameters for gradient descent
learning_rate = 0.1
epochs = 20
x_init, y_init = 4, 4  # Starting point

# Store the values for visualization
x_vals = [x_init]
y_vals = [y_init]
f_vals = [f(x_init, y_init)]

# Perform gradient descent
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]
    
    # Record the new positions
    x_vals.append(x)
    y_vals.append(y)
    f_vals.append(f(x, y))

# Generate grid points for contour plot
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)

# Plot the contour lines
plt.figure(figsize=(8, 6))
contours = plt.contour(X, Y, Z, levels=30, cmap='viridis')
plt.clabel(contours)

# Plot the gradient descent path
plt.plot(x_vals, y_vals, 'ro-', markersize=3, label='Gradient Descent')

# Add arrows to show direction of descent
for i in range(1, len(x_vals), 20):
    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.2, head_length=0.2, fc='red', ec='red')

# Set up plot labels and formatting
plt.title("Gradient Descent on Perturbed Quadratic Function")
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()

1.6 Descente de gradient stochastique

  • Problème : Minimiser \(f(\mathbf{x})\) alors que \(\nabla f\) est coûteux à calculer
  • On suppose que l’on dispose d’un estimateur \(G\) de \(\nabla f\), sans biais: \[E[G(x)] = \nabla f(x)\]
  • On suppose \(G\) moins coûteux
  • On appelle \(G\) le gradient stochastique
  • SGD
    • On échantillonne \(G(x_n)\)
    • Mise à jour des paramètres : \[ x_{n+1} = x_{n} - \eta G(x_n) \]
import numpy as np
import matplotlib.pyplot as plt

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

# Gradient of the perturbed quadratic function with noise (stochastic)
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])

# Parameters for stochastic gradient descent
learning_rate = 0.1
epochs = 20
x_init, y_init = 4, 4  # Starting point

# Store the values for visualization
x_vals = [x_init]
y_vals = [y_init]
f_vals = [f(x_init, y_init)]

# Perform stochastic gradient descent
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]
    
    # Record the new positions
    x_vals.append(x)
    y_vals.append(y)
    f_vals.append(f(x, y))

# Generate grid points for contour plot
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)

# Plot the contour lines
plt.figure(figsize=(8, 6))
contours = plt.contour(X, Y, Z, levels=30, cmap='viridis')
plt.clabel(contours)

# Plot the stochastic gradient descent path
plt.plot(x_vals, y_vals, 'ro-', markersize=3, label='SGD Path')

# Add arrows to show direction of stochastic descent
for i in range(1, len(x_vals), 5):
    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.2, head_length=0.2, fc='red', ec='red')

# Set up plot labels and formatting
plt.title("Stochastic Gradient Descent (SGD) on Perturbed Quadratic Function")
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()

1.7 SGD dans la pratique

Fonction de coût:

  • modèle \(f_\theta\)
  • on cherche à minimiser sur \(\theta\)
  • on somme le coût sur toutes les données \((x_i,y_i)\) du dataset d’entraînement \[ l(\theta) = \sum_i distance(f_\theta(x_i),y_i) \]

Gradient stochastique:

  • on découpe le dataset en batches \(B_1,...,B_k\)
  • On choisit un batch au hasard
  • On évalue la fonction de coût sur ce batch
  • C’est bien sans biais! (si les batches sont de même taille)

2 Differentiation automatique

2.1 Objectif:

Pour calculer le gradient de la fonction de coût:

  • différences finies : trop lent !
  • sur papier: bien trop compliqué; il faudrait refaire le calcul dès que le modèle change.

Solution:

  • la différentiation automatique avec pytorch !
  • permet de calculer automatiquement des dérivées d’expressions mathématiques / programmes informatiques

2.2 Principe:

Comment cela fonctionne:

  • pytorch observe les opérations mathématiques effectuées par l’évaluation du modèle : addition, multiplication, exponentielle, fonction trigonométrique, etc
  • pytorch écrit le modèle comme une composition de ces fonctions mathématiques usuelles (*)
  • pytorch connaît les dérivées de ces fonctions mathématiques usuelles
  • pytorch utilise alors la règle de dérivée de composée de fonctions pour évaluer le gradient

(*) c’est une simplification: en réalité pytorch crée un arbre décrivant l’expression mathématique du modèle

2.3 Différentielle d’une composée

Soit \(f_1:\mathbb{R}^n \rightarrow \mathbb{R}\), \(f_2:\mathbb{R}^n \rightarrow \mathbb{R}^n\) et \(f_3:\mathbb{R}^n \rightarrow \mathbb{R}^n\) posons \[f=f_1\circ f_2\circ f_3 \]

Pour calculer la différentielle de \(f\) en \(x\), posons: \[A_1=J_{f_1}(f_2of_3(x))\in M_{n,1}(\mathbb{R})\] \[A_2=J_{f_2}(f_3(x))\in M_{n,n}(\mathbb{R})\] \[A_3=J_{f_3}(x)\in M_{n,n}(\mathbb{R})\] Le calcul de la différentielle nous donne: \[J_f(x)=A_1 A_2 A_3\]

2.4 Coût du calcul de \(A_1 A_2 A_3\):

Il y a deux manières de faire ce produit matriciel, l’une est plus coûteuse que l’autre:

Forward: \(J_f(x)=A_1 (A_2 A_3)\)

  • Posons \(B=A_2A_3\)
  • Coût de calcul de \(B\): \(n^3\) ; coût de calcul de \(A_1B\): \(n^2\)
  • Coût total: \(O(n^3)\)

Backward: \(J_f(x)=(A_1 A_2) A_3\)

  • Posons \(C=A_1A_2\)
  • Coût de calcul de \(C\): \(n^2\) ; coût de calcul de \(CA_3\) : \(n^2\)
  • Coût total: \(O(n^2)\)

\[A_1=J_{f_1}(f_2of_3(x))\in M_{n,1}(\mathbb{R})\] \[A_2=J_{f_2}(f_3(x))\in M_{n,n}(\mathbb{R})\] \[A_3=J_{f_3}(x)\in M_{n,n}(\mathbb{R})\]

2.5 Différentiation automatique : Forward vs Backward

Les bibliothèques de différentiation automatique :

  • proposent 2 modes: forward et backward
  • le mode forward est plus rapide si l’espace de départ est plus petit que celui d’arrivée
  • le mode backward est plus rapide si l’espace d’arrivée est plus petit que l’espace de départ

Pour l’apprentissage:

  • l’espace d’arrivée de la fonction de coût est \(\mathbb{R}\)
  • on choisit donc toujours le mode backward

2.6 Avec pytorch

import torch

# Define a tensor with requires_grad=True to track computations
x = torch.tensor(3.0, requires_grad=True)

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

y.backward()

# The gradient of y with respect to x is stored in x.grad
print(f"dy/dx = {x.grad.item()} at x =",x.item())
y = 12.0
dy/dx = 7.0 at x = 3.0
  • on initialise \(x = 3\)
  • on indique que l’on souhaite calculer des gradients par rapport à \(x\) avec requires_grad=True
  • on calcule \(y = x^2 + x\)
  • on effectue une differentiation automatique backward

Résultat:

  • y.backward() évalue \(dx/dy\) en \(x = 3\)
  • le résultat est stocké dans x.grad

2.7 Attention

  • Pytorch accumule les gradients
  • Si on évalue le gradient de deux expressions dépendant d’un tenseur \(x\), les deux gradients seront sommés!
  • C’est très utile pour pytorch
  • Mais il ne faut pas oublier de remettre les gradients à 0 entre deux calculs indépendants !

3 Apprentissage avec pytorch

3.1 Charger les données

Pytorch distingue 2 concepts :

  • le Dataset, qui charge le dataset à la demande
  • la classe DataLoader qui permet d’itérer le dataset et de créer des batches (pour l’algorithme de descente de gradient stochastique)

3.2 Dataset: depuis un tenseur

  • charge un dataset depuis un tableau de données et un tableau de labels
  • pour accéder à la taille du dataset : __len__()
  • pour accéder au i-ème élément : __getitem(i)__
from torch.utils.data import TensorDataset

# Exemple de dataset simple
data = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
labels = torch.tensor([[0.0], [0.0], [1.0], [1.0]])

# Création d'un TensorDataset
dataset = TensorDataset(data, labels)

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

3.3 Dataset: custom

Pour charger un dataset, il faut implémenter la classe dataset comme suit :

from torch.utils.data import Dataset

class CustomDataset(Dataset):
    def __init__(self, some_args, transform=None, target_transform=None):
        # initialize whatever you need here
        
    def __len__(self):
        #return the length of the dataset

    def __getitem__(self, idx):
        #load data at index = idx
        data = ....
        label = ....
        return data, label

Usage:

  • __init__ : initialise le dataset. Par exemple avec le répertoire où se trouve le dataset
  • __len__ : retourne la taille du dataset
  • __get_item__ : retourne une donnée du dataset

Custom vs TensorDataset:

  • permet de charger les éléments à la demande depuis des fichiers
  • utile si le dataset prend trop de place dans la mémoire RAM

3.4 DataLoader

from torch.utils.data import DataLoader, TensorDataset

# Exemple de dataset simple
data = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
labels = torch.tensor([[0.0], [0.0], [1.0], [1.0]])

# Création d'un TensorDataset
dataset = TensorDataset(data, labels)

# Initialisation du DataLoader
dataloader = DataLoader(dataset, batch_size=2, shuffle=True)

# Iteration sur le DataLoader
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.]])
  • Dataloader: crée un itérateur pour parcourir le dataset en batches.
  • Batch Size : Nombre d’échantillons par batch.
  • Shuffle : Indique si les données doivent être mélangées.

3.5 modèle: FashionMNIST

On reprend le modèle du cours précédent, ainsi que les données du cours précédent :

from torch.utils.data import Dataset
from torchvision import datasets
from torchvision.transforms import ToTensor

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

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

import matplotlib.pyplot as plt
labels_map = {
    0: "T-Shirt",
    1: "Trouser",
    2: "Pullover",
    3: "Dress",
    4: "Coat",
    5: "Sandal",
    6: "Shirt",
    7: "Sneaker",
    8: "Bag",
    9: "Ankle Boot",
}
figure = plt.figure(figsize=(8, 8))
cols, rows = 3, 3
for i in range(1, cols * rows + 1):
    sample_idx = torch.randint(len(training_data), size=(1,)).item()
    img, label = training_data[sample_idx]
    figure.add_subplot(rows, cols, i)
    plt.title(labels_map[label])
    plt.axis("off")
    plt.imshow(img.squeeze(), cmap="gray")
plt.show()

import torch
import torch.nn as nn
import torch.nn.functional as F

class SimpleNN(nn.Module):
    def __init__(self):
        super(SimpleNN, self).__init__()
        self.flatten = nn.Flatten()
        # Définir les poids et biais pour trois couches
        self.w1 = nn.Parameter(torch.randn(28*28,512))
        self.b1 = nn.Parameter(torch.randn(512))
        self.w2 = nn.Parameter(torch.randn(512, 512))
        self.b2 = nn.Parameter(torch.randn(512))
        self.w3 = nn.Parameter(torch.randn(512, 10))
        self.b3 = nn.Parameter(torch.randn(10))
    
    def forward(self, x):
        x = self.flatten(x)
        # Appliquer les transformations et les fonctions d'activation
        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

# Instancier le modèle
model = SimpleNN()

3.6 Fonction de coût

Pour évaluer le coût sur un batch de données:

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

loss_function= torch.nn.CrossEntropyLoss()

# Calculer la perte pour un batch
for batch_data, batch_labels in dataloader:
    outputs = model(batch_data)
    loss = loss_function(outputs, batch_labels)
    print(f'Loss: {loss.item()}')
    break  # Calcul de la perte pour un seul batch
Loss: 3317.5478515625

3.7 descente de gradient

train_loader = DataLoader(dataset=training_data, 
                          batch_size=len(training_data), shuffle=True)
full_batch_data, full_batch_labels = next(iter(train_loader))

learning_rate = 0.01
num_steps = 6

for i in range(num_steps):
    # Zero the gradients
    model.zero_grad()

    # Forward pass
    outputs = model(full_batch_data)
    loss = loss_function(outputs, full_batch_labels)
    
    # Backward pass
    loss.backward()
    
    # Update weights manually
    with torch.no_grad():
        for param in model.parameters():
            param -= learning_rate * param.grad

    print(f'step [{i+1}/{num_steps}], Loss: {loss.item():.4f}')
step [1/6], Loss: 6830.8149
step [2/6], Loss: 13874.2861
step [3/6], Loss: 21357.0098
step [4/6], Loss: 8775.4424
step [5/6], Loss: 3938.7498
step [6/6], Loss: 1733.2336
  • batch size: tout le dataset
  • next(iter(…)): renvoie le premier élément (non spécifique à pytorch!)
  • model.zero_grad(): met le gradient à 0
  • le gradient est automatiquement calculé sur les paramètres du modèle
  • with torch.no_grad(): désactive le calcul du gradient pour la mise à jour des coefficients

3.8 descente de gradient stochastique (SGD)

Estimateur du gradient:

  • on ne calcule pas le gradient sur l’ensemble des données
  • pour cela, on utilise des données en batch
  • petits batches: rapide à calculer
  • grands batches: estimateur de faible variance

epoch :

  • correspond à un passage complet à travers l’ensemble du jeu de données d’entraînement par l’algorithme de formation.
  • Cela signifie que chaque échantillon du jeu de données a été utilisé une fois pour mettre à jour les paramètres du modèle.

3.9 SGD avec pytorch

model = SimpleNN() #reinitialise le modèle
learning_rate = 0.01
num_epochs = 3
batch_size = 10000

train_loader = DataLoader(dataset=training_data, 
                          batch_size=batch_size, shuffle=True)

for epoch in range(num_epochs):
    for batch_data, batch_labels in train_loader:
        i=i+1
        # Zero the gradients
        model.zero_grad()

        # Forward pass
        outputs = model(batch_data)
        loss = loss_function(outputs, batch_labels)

        # Backward pass
        loss.backward()

        # Update weights manually
        with torch.no_grad():
            for param in model.parameters():
                param -= learning_rate * param.grad

    print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')
Epoch [1/3], Loss: 1924.2140
Epoch [2/3], Loss: 234.3743
Epoch [3/3], Loss: 163.0438
  • batch_size: taille du batch
  • num_epochs: nombre de fois que l’on parcourt le dataset

3.10 Optimizer

model = SimpleNN() #reinitialise le modèle
num_epochs = 3
batch_size = 10000

train_loader = DataLoader(dataset=training_data, 
                          batch_size=batch_size, shuffle=True)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

i=0
for epoch in range(num_epochs):
    for batch_data, batch_labels in train_loader:
        i=i+1
        # Zero the gradients
        optimizer.zero_grad()

        # Forward pass
        outputs = model(batch_data)
        loss = loss_function(outputs, batch_labels)

        # Backward pass
        loss.backward()

        # Update weights
        optimizer.step()

    print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')
Epoch [1/3], Loss: 1475.9435
Epoch [2/3], Loss: 197.3361
Epoch [3/3], Loss: 162.0368
  • torch.optim.SGD: fonction d’optimisation SGD de pytorch
  • optimizer.zero_grad(): mise à 0 du gradient
  • optimizer.step(): mise à jour des paramètres du modèle

4 Problèmes de l’algorithme d’apprentissage

4.1 Mauvais learning rate

4.1.1 Problème de Taux d’Apprentissage Inadapté

Le taux d’apprentissage est un hyperparamètre clé qui contrôle la vitesse à laquelle un modèle ajuste ses poids pendant l’entraînement.

  • Un taux d’apprentissage trop élevé peut entraîner des oscillations autour du minimum global ou même empêcher la convergence.
  • Un taux d’apprentissage trop faible ralentit le processus d’entraînement, risquant de piéger le modèle dans un minimum local ou de stagner.
# Import des bibliothèques nécessaires
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt

# Génération de données artificielles pour un problème de régression
x = torch.unsqueeze(torch.linspace(-5, 5, 100), dim=1)
y = x.pow(2) + 0.2 * torch.rand(x.size())  # Fonction quadratique avec un peu de bruit

# Définition d'un modèle simple de régression
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)

# Initialisation du modèle
model = SimpleNet()
criterion = nn.MSELoss()
# Taux d'apprentissage élevé
model_high_lr = SimpleNet()  # Réinitialisation du modèle
optimizer_high_lr = optim.SGD(model_high_lr.parameters(), lr=0.1)  # Taux d'apprentissage élevé

# Entraînement avec un taux d'apprentissage élevé
losses_high_lr = []
for epoch in range(50):
    prediction = model_high_lr(x)
    loss = criterion(prediction, y)
    optimizer_high_lr.zero_grad()
    loss.backward()
    optimizer_high_lr.step()
    losses_high_lr.append(loss.item())

# Visualisation de la perte
#plt.plot(losses_high_lr, label="High Learning Rate (0.1)")
#plt.xlabel("Epoch")
#plt.ylabel("Loss")
#plt.title("Oscillations dues à un Taux d'Apprentissage Trop Élevé")
#plt.legend()
#plt.show()
# Taux d'apprentissage faible
model_low_lr = SimpleNet()  # Réinitialisation du modèle
optimizer_low_lr = optim.SGD(model_low_lr.parameters(), lr=0.0001)  # Taux d'apprentissage faible

# Entraînement avec un taux d'apprentissage faible
losses_low_lr = []
for epoch in range(50):
    prediction = model_low_lr(x)
    loss = criterion(prediction, y)
    optimizer_low_lr.zero_grad()
    loss.backward()
    optimizer_low_lr.step()
    losses_low_lr.append(loss.item())

# Visualisation de la perte
#plt.plot(losses_low_lr, label="Low Learning Rate (0.0001)")
#plt.xlabel("Epoch")
#plt.ylabel("Loss")
#plt.title("Lente Convergence due à un Taux d'Apprentissage Trop Faible")
#plt.legend()
#plt.show()
# Taux d'apprentissage optimal
model_optimal_lr = SimpleNet()  # Réinitialisation du modèle
optimizer_optimal_lr = optim.SGD(model_optimal_lr.parameters(), lr=0.01)  # Taux d'apprentissage optimal

# Entraînement avec un taux d'apprentissage optimal
losses_optimal_lr = []
for epoch in range(50):
    prediction = model_optimal_lr(x)
    loss = criterion(prediction, y)
    optimizer_optimal_lr.zero_grad()
    loss.backward()
    optimizer_optimal_lr.step()
    losses_optimal_lr.append(loss.item())

# Visualisation de la perte
#plt.plot(losses_optimal_lr, label="Optimal Learning Rate (0.01)")
#plt.xlabel("Epoch")
#plt.ylabel("Loss")
#plt.title("Convergence avec un Taux d'Apprentissage Optimal")
#plt.legend()
#plt.show()
# Comparaison de tous les taux d'apprentissage
plt.plot(losses_high_lr, label="High LR (0.1)", linestyle='--')
plt.plot(losses_low_lr, label="Low LR (0.0001)", linestyle='--')
plt.plot(losses_optimal_lr, label="Optimal LR (0.01)", linestyle='-')
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.yscale("log")
plt.title("Comparaison des Taux d'Apprentissage")
plt.legend()
plt.show()

4.1.2 Learning schedule

  • Les schedulers de learning rate ajustent automatiquement le taux d’apprentissage au cours de l’entraînement.

2 grands types:

  • réduire le learning rate au cours du temps pour converger vers un minimum (exemple: StepLR)
  • réduire le learning rate, puis le remonter, pour sortir des minimums locaux (exemple: CosineAnnealingWarmRestarts)
import torch
import torch.optim as optim
import torch.nn as nn
import matplotlib.pyplot as plt

# Modèle simple et données fictives
model = nn.Linear(10, 1)
criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.1)

# Paramètres d'entraînement
epochs = 100
train_errors_step = []
train_errors_cosine_restart = []
learning_rates_step = []
learning_rates_cosine_restart = []

# Schedulers
scheduler_step = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
scheduler_cosine_restart = optim.lr_scheduler.CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2)

# Entraînement avec StepLR
for epoch in range(epochs):
    # Simulation d'une erreur d'entraînement décroissante
    train_loss = 1 / (epoch + 1) + torch.rand(1).item() * 0.05
    train_errors_step.append(train_loss)
    learning_rates_step.append(optimizer.param_groups[0]["lr"])
    scheduler_step.step()

# Réinitialisation de l'optimiseur et du scheduler pour CosineAnnealingWarmRestarts
optimizer = optim.SGD(model.parameters(), lr=0.1)
scheduler_cosine_restart = optim.lr_scheduler.CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2)

# Entraînement avec CosineAnnealingWarmRestarts
for epoch in range(epochs):
    # Simulation d'une erreur d'entraînement décroissante
    train_loss = 1 / (epoch + 1) + torch.rand(1).item() * 0.05
    train_errors_cosine_restart.append(train_loss)
    learning_rates_cosine_restart.append(optimizer.param_groups[0]["lr"])
    scheduler_cosine_restart.step(epoch)  # Utiliser epoch comme paramètre pour le redémarrage

# Visualisation des learning rates et erreurs d'entraînement
plt.figure(figsize=(14, 5))

# Plot des learning rates
plt.subplot(1, 2, 1)
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('Learning Rate Schedulers')
plt.legend()
plt.grid(True)

# Plot des erreurs d'entraînement
#plt.subplot(1, 2, 2)
#plt.plot(range(epochs), train_errors_step, label='StepLR')
#plt.plot(range(epochs), train_errors_cosine_restart, label='CosineAnnealingWarmRestarts')
#plt.xlabel('Epoch')
#plt.ylabel('Training Error')
#plt.yscale("log")
#plt.title('Training Error with Cosine Annealing + Restarts')
#plt.legend()
#plt.grid(True)

plt.tight_layout()
plt.show()
/tmp/ipykernel_1634263/4228098559.py:28: 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()

4.2 Saddle points

4.2.1 Saddle points

  • Saddle Point : Point où le gradient est nul mais qui n’est ni un minimum ni un maximum
  • Minimum Local : Point où le gradient est nul et représente un minimum de la fonction de perte
  • Problème : La descente de gradient peut s’arrêter ou ralentir aux points selle
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

# Génération d'une surface avec un point selle
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 : z = x^2 - y^2

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()

4.2.2 Solution: changer d’optimizer

  • Descente de gradient classique : Reste bloquée facilement aux points selle
  • Adam Optimizer :
    • Utilise des moments pour adapter les pas de gradient
    • Aide à échapper aux points selle et à optimiser plus rapidement
import torch
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

# Fonction avec minimum local autour de l'origine
def saddle_function(x, y):
    return x**2 - y**2 + 0.1 * (x**4 + y**4)

# Générer les données pour la surface
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)  # Fonction modifiée

# Initialisation pour l'optimisation
x = torch.tensor(-1.5, requires_grad=True)
y = torch.tensor(-0.0001, requires_grad=True)

# Optimizer SGD (descente de gradient classique)
optimizer_sgd = optim.SGD([x, y], lr=0.1)
trajectory_sgd = []
loss = saddle_function(x, y)
trajectory_sgd.append((x.item(), y.item(), loss.item()))
for _ in range(50):
    optimizer_sgd.zero_grad()
    loss = saddle_function(x, y)
    loss.backward()
    optimizer_sgd.step()
    trajectory_sgd.append((x.item(), y.item(), loss.item()))

# Réinitialiser x et y pour Adam
x = torch.tensor(-1.5, requires_grad=True)
y = torch.tensor(-0.0001, requires_grad=True)

# Optimizer Adam
optimizer_adam = optim.Adam([x, y], lr=0.1)
trajectory_adam = []
loss = saddle_function(x, y)
trajectory_adam.append((x.item(), y.item(), loss.item()))
for _ in range(50):
    optimizer_adam.zero_grad()
    loss = saddle_function(x, y)
    loss.backward()
    optimizer_adam.step()
    trajectory_adam.append((x.item(), y.item(), loss.item()))

# Visualiser les trajectoires avec la surface (3D plot)
fig = plt.figure(figsize=(14, 6))

# Plot 1: Surface en 3D avec trajectoires
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 Trajectory')
ax1.plot(adam_x, adam_y, adam_z, marker='o', color='orange', label='Adam Trajectory')
ax1.set_title("SGD vs Adam, 50 steps")
ax1.set_xlabel('X')
ax1.set_ylabel('Y')
ax1.set_zlabel('Loss')
ax1.legend()

# Plot 2: Contour 2D avec couleurs et trajectoires projetées
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 Value')
ax2.plot(sgd_x, sgd_y, marker='o', color='blue', label='SGD Trajectory')
ax2.plot(adam_x, adam_y, marker='o', color='orange', label='Adam Trajectory')
ax2.set_title("SGD vs Adam, 50 steps")
ax2.set_xlabel('X')
ax2.set_ylabel('Y')
ax2.legend()

plt.tight_layout()
plt.show()

4.3 Overfitting

4.3.1 Early stopping

  • Arrête l’entraînement quand la performance sur le set de validation cesse de s’améliorer

  • Empêche le modèle de trop s’ajuster aux données d’entraînement

  • Critère de patience :

    • Nombre d’epochs où l’on tolère une stagnation de la performance
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

# Create synthetic data
x = torch.linspace(-1, 1, 200).reshape(-1, 1)
y = x**3 + 0.2 * torch.sin(5 * x) + 1 * torch.randn(x.size())  # Complex function with noise

# Split the dataset into training and test sets
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=42)

# Define a more complex model
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)

# Define a function for training the model with or without early stopping
def train_model_with_early_stopping(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):
        # Train the model
        optimizer.zero_grad()
        y_pred_train = model(x_train)
        loss_train = loss_fn(y_pred_train, y_train)
        loss_train.backward()
        optimizer.step()
        
        # Calculate the loss on the test set
        with torch.no_grad():
            y_pred_test = model(x_test)
            loss_test = loss_fn(y_pred_test, y_test)
        
        train_losses.append(loss_train.item())
        test_losses.append(loss_test.item())

        # Early stopping criterion
        if early_stopping:
            if loss_test < best_loss:
                best_loss = loss_test
                patience_counter = 0  # reset the counter
            else:
                patience_counter += 1

            if patience_counter >= patience:
                print(f"Early stopping at epoch {epoch + 1}")
                break

    return train_losses, test_losses

# Initialize the models
model_early_stopped = ComplexNet()
optimizer_early_stopped = optim.SGD(model_early_stopped.parameters(), lr=0.05)
loss_fn = nn.MSELoss()

model_no_early_stopping = ComplexNet()
optimizer_no_early_stopping = optim.SGD(model_no_early_stopping.parameters(), lr=0.05)

# Train both models
train_losses_early, test_losses_early = train_model_with_early_stopping(model_early_stopped, optimizer_early_stopped, loss_fn, early_stopping=True)
train_losses_no_early, test_losses_no_early = train_model_with_early_stopping(model_no_early_stopping, optimizer_no_early_stopping, loss_fn, early_stopping=False)

# Plot the results
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))

# Training loss plot
ax1.plot(train_losses_no_early, label='No Early Stopping', linestyle='--')
ax1.plot(train_losses_early, label='Early Stopped')
ax1.set_xlabel("Epochs")
ax1.set_ylabel("Training Loss")
ax1.legend()
ax1.set_title("Training Loss")

# Test loss plot
ax2.plot(test_losses_no_early, label='No Early Stopping', linestyle='--')
ax2.plot(test_losses_early, label='Early Stopped')
ax2.set_xlabel("Epochs")
ax2.set_ylabel("Test Loss")
ax2.legend()
ax2.set_title("Test Loss")

plt.tight_layout()
plt.show()
Early stopping at epoch 30

4.3.2 Regularisation

  • Ajoute une pénalité à la fonction de coût pour des poids trop élevés

  • Réduit la complexité du modèle et favorise la généralisation

  • Types de régularisation :

    • L2 Regularization (Ridge) : \(\text{Penalty} = \lambda \sum w^2\)
    • L1 Regularization (Lasso) : \(\text{Penalty} = \lambda \sum |w|\)
    • Dropout : Désactive aléatoirement des neurones pendant l’entraînement
  • Avantages :

    • Diminue la variance
    • Empêche le surajustement aux données d’entraînement
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

# Création d'un jeu de données unique
x = torch.linspace(-1, 1, 20).reshape(-1, 1)
y = x**3 + 0.2 * torch.sin(5 * x) + 1 * torch.randn(x.size())  # Complex function with noise

# Séparation du jeu de données en ensembles d'entraînement et de test
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2, random_state=42)

# Définition d'un modèle plus complexe
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)

# Modèle sans régularisation
model_no_reg = ComplexNet()
optimizer_no_reg = optim.SGD(model_no_reg.parameters(), lr=0.02)
scheduler_no_reg = optim.lr_scheduler.StepLR(optimizer_no_reg, step_size=200, gamma=0.5)

# Modèle avec régularisation L2 (weight decay)
model_with_reg = ComplexNet()
optimizer_with_reg = optim.SGD(model_with_reg.parameters(), lr=0.02, weight_decay=0.1)
scheduler_with_reg = optim.lr_scheduler.StepLR(optimizer_with_reg, step_size=200, gamma=0.5)

# Entraînement des deux modèles
epochs = 500
loss_fn = nn.MSELoss()
train_losses_no_reg, test_losses_no_reg = [], []
train_losses_with_reg, test_losses_with_reg = [], []

for epoch in range(epochs):
    # Modèle sans régularisation
    optimizer_no_reg.zero_grad()
    y_pred_train = model_no_reg(x_train)
    loss_train = loss_fn(y_pred_train, y_train)
    loss_train.backward()
    optimizer_no_reg.step()
    scheduler_no_reg.step()
    train_losses_no_reg.append(loss_train.item())

    # Calcul de la perte sur le jeu de test
    with torch.no_grad():
        y_pred_test = model_no_reg(x_test)
        loss_test = loss_fn(y_pred_test, y_test)
        test_losses_no_reg.append(loss_test.item())

    # Modèle avec régularisation
    optimizer_with_reg.zero_grad()
    y_pred_train_reg = model_with_reg(x_train)
    loss_train_reg = loss_fn(y_pred_train_reg, y_train)
    loss_train_reg.backward()
    optimizer_with_reg.step()
    scheduler_with_reg.step()
    train_losses_with_reg.append(loss_train_reg.item())

    # Calcul de la perte sur le jeu de test (avec régularisation)
    with torch.no_grad():
        y_pred_test_reg = model_with_reg(x_test)
        loss_test_reg = loss_fn(y_pred_test_reg, y_test)
        test_losses_with_reg.append(loss_test_reg.item())

# Affichage des courbes de perte
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))

# Perte sur l'ensemble d'entraînement
ax1.plot(train_losses_no_reg, label='Sans régularisation')
ax1.plot(train_losses_with_reg, label='Avec régularisation L2', linestyle='--')
ax1.set_xlabel("Epochs")
ax1.set_ylabel("Training Loss")
ax1.legend()
ax1.set_title("Perte sur l'ensemble d'entraînement")

# Perte sur l'ensemble de test
ax2.plot(test_losses_no_reg, label='Sans régularisation')
ax2.plot(test_losses_with_reg, label='Avec régularisation L2', linestyle='--')
ax2.set_xlabel("Epochs")
ax2.set_ylabel("Test Loss")
ax2.legend()
ax2.set_title("Perte sur l'ensemble de test")

plt.tight_layout()
plt.show()

5 Généralisation

5.1 Le paradoxe

  • Théorie classique (VC dimension, Rademacher complexity…) : plus de paramètres que d’exemples \(\Rightarrow\) sur-apprentissage garanti
  • Les réseaux modernes ont souvent bien plus de paramètres que d’exemples d’entraînement
  • Pourtant, ils généralisent très bien en pratique
  • La théorie classique prédit l’échec ; l’expérience dit le contraire

5.2 Zhang et al. (ICLR 2017)

“Understanding deep learning requires rethinking generalization”

  • Expérience : entraîner un CNN avec des labels aléatoires (aucune structure, pur bruit)
  • Résultat : le réseau atteint \(0\%\) d’erreur d’entraînement — il mémorise parfaitement le bruit
  • Le même réseau, mêmes hyperparamètres, généralise bien sur de vraies données
  • Conclusion : la capacité du réseau suffit à tout mémoriser ; les mesures classiques de capacité ne peuvent donc pas, à elles seules, expliquer pourquoi il généralise sur de vraies données

5.3 Double descent

  • Intuition classique : plus de capacité \(\Rightarrow\) d’abord ça aide, puis ça nuit (courbe en U, biais-variance)
  • Observation empirique : au-delà du point d’interpolation (le modèle peut ajuster parfaitement l’entraînement), l’erreur de test rebaisse
  • Deux régimes séparés par un pic d’erreur : “classique” (sous-paramétré) puis “moderne” (sur-paramétré)
  • Observé sur de nombreuses architectures, y compris de simples modèles linéaires à features aléatoires
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))
beta_true = rng.normal(size=d)
noise_std = 0.5

y_train = X_train @ beta_true + noise_std * rng.normal(size=n_train)
y_test = X_test @ beta_true + noise_std * rng.normal(size=n_test)

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

p_list = [1, 2, 5, 10, 20, 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.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()

5.4 Constat

  • Pic d’erreur de test exactement au point d’interpolation (\(p \approx n\))
  • Passé ce point, ajouter encore plus de paramètres… aide à nouveau
  • Exactement le phénomène de double descent

5.5 Soyons honnêtes

  • Il n’existe pas aujourd’hui de théorie complète expliquant pourquoi les réseaux sur-paramétrés généralisent
  • Plusieurs pistes (marge, platitude des minima, biais implicite de SGD, NTK…) mais aucune ne fait consensus
  • Ce que l’expérience montre est, pour l’instant, en avance sur ce que la théorie explique