Apprentissage

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) \] où \(\eta\) est le learning rate.
  • Convergence : \(x_n\) converge vers un minimum local sous certaines conditions (dont \(\eta \rightarrow 0\))

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) \] où \(\eta\) est le learning rate.

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) \]

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([[2.],
        [1.]]) labels: tensor([[0.],
        [0.]])
data: tensor([[3.],
        [4.]]) labels: tensor([[1.],
        [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 :

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: 8850.0703125

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: 3760.8013
step [2/6], Loss: 15396.2969
step [3/6], Loss: 15643.5566
step [4/6], Loss: 8759.0859
step [5/6], Loss: 4254.5693
step [6/6], Loss: 2324.6482
  • 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

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: 1397.9691
Epoch [2/3], Loss: 273.5677
Epoch [3/3], Loss: 154.3725
  • batch_size: taille du batch
  • num_epochs: nombre de fois que l’on parcourt le dataset

3.10 Optimizer

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: 894.4625
Epoch [2/3], Loss: 314.3895
Epoch [3/3], Loss: 178.6331
  • 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.

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)

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

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

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

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

5.4 Constat

  • Régime classique visible : jusqu’à \(p \approx d_{true}\) (la vraie complexité du problème), plus de capacité aide, l’erreur de test baisse
  • Au-delà, l’erreur remonte, avec un pic exactement au point d’interpolation (\(p \approx n\))
  • Passé ce point, ajouter encore plus de paramètres… aide de nouveau
  • Les deux descentes sont bien là : c’est 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