DL · Chapitre 1
Deep learning 1 — Le neurone linéaire et la descente de gradient
Avec ce chapitre, nous franchissons une frontière importante. Jusqu'ici, en machine learning, nous avons utilisé des modèles déjà tout faits, fournis par scikit-learn : régression linéaire, plus proches voisins, arbres, forêts. L'apprentissage proprement dit était caché derrière une simple méthode fit. Nous allons maintenant ouvrir la boîte noire et reconstruire, brique par brique, le mécanisme qui permet à un modèle d'apprendre à partir de données.
L'objet d'étude est volontairement le plus simple imaginable : un neurone linéaire. Ce neurone n'a rien de mystérieux. C'est, mathématiquement, exactement la même chose qu'une régression linéaire. Mais nous allons le reformuler dans le vocabulaire des réseaux de neurones — poids, biais, forward pass, gradient, epoch, learning rate — et surtout, nous allons l'entraîner à la main, par descente de gradient, pour bien voir ce qui se passe sous le capot. Ce sont précisément ces mécanismes qui, généralisés et empilés, donneront ensuite les réseaux profonds que l'on rencontre partout aujourd'hui.
Le chapitre suit une progression linéaire. Nous commençons par un neurone à une seule entrée scalaire . Nous démontrons les formules de gradient pour la MSE, puis nous codons une classe Python LinearNeuron1D qui apprend par descente de gradient batch. Nous introduisons ensuite les variantes stochastique (SGD) et minibatch, qui sont celles utilisées en pratique. Nous généralisons enfin au cas multidimensionnel avec une classe LinearNeuron, ce qui nous oblige à passer en notation matricielle. Le chapitre se termine sur un point capital — la normalisation des entrées — sans laquelle la descente de gradient se comporte très mal sur des données réelles.
Tout le code repose sur les imports habituels :
import numpy as np import matplotlib.pyplot as plt import pandas as pd from sklearn.metrics import mean_squared_error, mean_absolute_error from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
Le neurone linéaire à une entrée
Considérons une seule variable d'entrée et une cible . Un neurone linéaire calcule :
où :
- est l'entrée scalaire,
- est le poids (weight) du neurone,
- est son biais (bias),
- est la prédiction (sortie du neurone).
C'est exactement la droite de régression que vous connaissez. Le changement de vocabulaire n'est pas anodin : dans un réseau profond, et deviendront un vecteur et un biais par neurone, puis une matrice et un vecteur par couche, mais l'opération de base restera la même. Apprendre, c'est ajuster ces paramètres pour que les prédictions soient en moyenne le plus proches possible des cibles observées.
La distance entre prédictions et cibles se mesure par une fonction de coût, ici l'erreur quadratique moyenne (MSE) :
Idée centrale — Apprendre, c'est résoudre le problème d'optimisation . Toute la suite du cours, jusqu'aux réseaux profonds, n'est qu'une généralisation de cette idée.
La descente de gradient
Pour minimiser , on pourrait dans ce cas particulier écrire les conditions et et résoudre le système (équations normales). Mais cette voie ne se généralise pas aux réseaux de neurones, où dépend de millions de paramètres et n'est plus quadratique. La voie générale, qui marche partout, est la descente de gradient.
Principe géométrique
Le gradient pointe dans la direction de plus forte croissance de . En suivant la direction opposée au gradient, par petits pas, on fait diminuer . À chaque itération, on applique :
Le coefficient s'appelle le learning rate ou taux d'apprentissage. Il contrôle l'amplitude des pas. S'il est trop petit, la convergence est lente et il faut beaucoup d'itérations. S'il est trop grand, on dépasse le minimum à chaque pas, la fonction de coût oscille, voire diverge. Ajuster est l'un des premiers réglages auxquels on pense lorsqu'un entraînement se passe mal.
Le learning rate ne change pas la direction du gradient, seulement la distance parcourue à chaque itération. C'est un curseur entre lenteur et instabilité.
Une epoch correspond à une itération complète où le gradient est recalculé à partir des données. Augmenter le nombre d'epochs permet d'approcher de plus en plus le minimum, mais au-delà d'un certain point on n'améliore plus la performance — voire on dégrade celle sur les données de test, ce qui sera étudié dans les chapitres suivants.
Calcul du gradient pour la MSE
Pour pouvoir coder la descente de gradient, il faut expliciter et . Pour simplifier le calcul, on introduit la perte par exemple :
Le facteur est purement cosmétique : il fera disparaître le facteur 2 issu de la dérivation. La fonction de coût globale s'écrit alors , ou plus simplement, si l'on garde la convention MSE classique sans le , . Les deux conventions diffèrent d'un facteur multiplicatif constant, qui peut être absorbé par le learning rate.
Par la règle de dérivation en chaîne :
et
En version batch, c'est-à-dire en moyennant sur l'ensemble des exemples du jeu d'apprentissage :
À retenir — Les deux gradients ont la même structure : on calcule l'erreur signée , on la pondère (par pour le gradient en , par 1 pour le gradient en ), puis on moyenne. Cette structure se retrouvera identique pour le neurone à entrées multiples, et plus tard pour les réseaux profonds.
Implémentation from scratch : LinearNeuron1D
Coder un neurone linéaire à la main est l'occasion de bien voir ce que fait l'entraînement. La classe que nous allons construire reproduit, en miniature, la structure que l'on retrouvera dans toutes les bibliothèques de deep learning : un objet qui contient des paramètres apprenables, expose une méthode forward pour calculer une prédiction et une méthode fit pour ajuster les paramètres par descente de gradient.
Concrètement :
__init__initialise et stocke les paramètres et . On peut les initialiser à zéro, ou plus prudemment à de petites valeurs aléatoires (np.random.uniform()).forward(x)calcule la prédiction . La méthode doit fonctionner sur un vecteur d'entrées, pour permettre le calcul vectorisé sur tous les points du jeu d'apprentissage en une seule opération NumPy.fit(x, y, lr, epochs)réalise l'apprentissage. À chaque epoch, on calcule les prédictions , puis les gradients et via les formules ci-dessus, puis on met à jour et .
En enregistrant la valeur de la loss à chaque epoch dans un attribut history, on peut tracer la courbe de convergence et diagnostiquer visuellement les problèmes : descente trop lente (loss qui décroît lentement), divergence (loss qui croît), oscillations (loss en dents de scie).
Voici le squelette de la classe :
class LinearNeuron1D: def __init__(self): self.a = np.random.uniform() self.b = np.random.uniform() self.history = [] def forward(self, x): return self.a * x + self.b def fit(self, x, y, lr=0.1, epochs=10): for _ in range(epochs): u = self.forward(x) grad_a = ((u - y) * x).mean() grad_b = (u - y).mean() self.a -= lr * grad_a self.b -= lr * grad_b self.history.append(((y - u) ** 2).mean()) return self.history
Notez à quel point le code suit littéralement les formules : (u - y) * x est l'erreur signée pondérée par , .mean() réalise la moyenne . C'est un avantage majeur du calcul vectorisé NumPy : le code mathématique et le code Python se ressemblent.
Sur le jeu de données abalone_mini, on peut prédire le nombre d'anneaux (Rings) à partir de la longueur (Length) :
df = pd.read_csv('/kaggle/input/datasets/pyim59/mini-datasets/abalone_mini.csv') x = df['Length'].to_numpy() y = df['Rings'].to_numpy() model = LinearNeuron1D() model.fit(x, y, lr=0.1, epochs=500) y_hat = model.forward(x) print(f"MAE : {mean_absolute_error(y, y_hat):.2f}") print(f"RMSE : {np.sqrt(mean_squared_error(y, y_hat)):.2f}") plt.plot(model.history) plt.xlabel("epoch") plt.ylabel("MSE")
La courbe de la loss doit décroître régulièrement et se stabiliser ; l'allure de cette courbe donne la première information utile sur la santé de l'apprentissage.
Batch, SGD et minibatch
La descente de gradient telle que nous venons de la coder est dite en batch : à chaque epoch, le gradient est calculé comme la moyenne des contributions de tous les exemples du jeu d'apprentissage. La mise à jour des paramètres est donc déterministe. Cette approche a deux limites pratiques. D'une part, sur de grands jeux de données (millions d'exemples), recalculer la moyenne complète à chaque epoch est très coûteux. D'autre part, en l'absence de bruit dans les mises à jour, l'algorithme peut rester piégé dans des minima locaux ou des plateaux de la fonction de coût.
SGD : descente de gradient stochastique
Une alternative extrême est la Stochastic Gradient Descent (SGD). À chaque mise à jour, on tire un seul exemple au hasard dans le jeu d'apprentissage, et on calcule le gradient à partir de ce seul exemple :
Cette estimation est non biaisée — en moyenne sur les tirages, elle vaut bien le gradient batch — mais elle est très bruitée. Les mises à jour sont rapides, mais la trajectoire de descente est irrégulière et la loss peut osciller fortement d'une itération à l'autre.
Minibatch : le compromis
Le minibatch gradient descent est le compromis utilisé en pratique dans toutes les bibliothèques de deep learning. À chaque mise à jour, on tire un sous-ensemble de taille d'exemples (typiquement , , , ) et on calcule la moyenne des gradients sur ce minibatch.
Distinction batch / SGD / minibatch
- Batch : gradient calculé sur l'ensemble des exemples → mise à jour déterministe, coûteuse, peu bruitée.
- SGD : gradient calculé sur 1 seul exemple → mise à jour rapide, très bruitée.
- Minibatch : gradient calculé sur exemples () → compromis entre coût et bruit. C'est la stratégie standard du deep learning moderne.
Le bruit modéré d'un minibatch a un effet régularisateur souvent bénéfique : il aide à sortir des plateaux et à éviter les minima locaux trop pointus. De plus, calculer gradients en parallèle se vectorise très bien sur GPU, ce qui rend le minibatch optimal d'un point de vue matériel.
Implémentation unifiée
On enrichit la classe avec un paramètre mode qui peut valoir "batch", "sgd" ou "minibatch", et un paramètre batch_size utilisé uniquement en mode minibatch. La logique de tirage des indices est la suivante :
- en mode
"batch", on prend tous les indices :idx = np.arange(n); - en mode
"sgd", on tire un seul indice aléatoire :idx = np.random.randint(n); - en mode
"minibatch", on tire indices :idx = np.random.choice(n, size=batch_size).
Le reste du code reste identique, à condition de remplacer x par x[idx] et y par y[idx] dans le calcul des gradients :
class LinearNeuron1D: def __init__(self): self.a = np.random.uniform() self.b = np.random.uniform() self.history = [] def forward(self, x): return self.a * x + self.b def fit(self, x, y, lr=0.1, epochs=10, mode="batch", batch_size=32): n = len(x) for _ in range(epochs): if mode == "batch": idx = np.arange(n) elif mode == "sgd": idx = np.array([np.random.randint(n)]) elif mode == "minibatch": idx = np.random.choice(n, size=batch_size) else: raise ValueError("mode non reconnu") u = self.forward(x[idx]) grad_a = ((u - y[idx]) * x[idx]).mean() grad_b = (u - y[idx]).mean() self.a -= lr * grad_a self.b -= lr * grad_b self.history.append(((y[idx] - u) ** 2).mean()) return self.history
En lançant successivement les trois modes avec un même learning rate, on observe bien que la trajectoire de la loss est lisse en batch, très bruitée en SGD, et intermédiaire en minibatch. Cette comparaison visuelle est l'une des manières les plus pédagogiques de comprendre la différence.
Le neurone linéaire à entrées multiples
Jusqu'ici, le neurone ne traitait qu'une seule variable d'entrée. Sur des données réelles, on dispose presque toujours de plusieurs variables explicatives (taille, poids, longueur, hauteur, surface, nombre de pièces…). On va donc généraliser le modèle à variables d'entrée.
Notation matricielle
Soient observations décrites chacune par variables. On regroupe les données dans une matrice de design :
où chaque ligne représente un exemple et chaque colonne une variable. Le neurone linéaire à entrées multiples est paramétré par :
- un vecteur de poids , un poids par variable d'entrée,
- un biais scalaire .
La prédiction sur l'ensemble des exemples s'écrit, en notation matricielle :
Le terme est ici implicitement broadcast sur les composantes du vecteur . On retrouve, sur chaque ligne , l'expression scalaire :
soit, pour , , qui n'est rien d'autre que notre modèle 1D de départ. Le neurone à entrées multiples est donc une généralisation directe.
Fonction de coût et gradient
La fonction de coût reste la MSE batch :
Le calcul des gradients se généralise très proprement. Pour le vecteur des poids, on obtient (toujours par règle de dérivation en chaîne) :
ou, à la convention près qu'on absorbe dans le learning rate :
C'est un vecteur de taille — un gradient par poids — qui s'interprète naturellement : pour chaque poids , on accumule les contributions sur tous les exemples, et on moyenne. Le produit matriciel réalise exactement cette accumulation pour les poids simultanément.
Pour le biais, la formule est inchangée :
Formule clé — En notation matricielle, la mise à jour batch d'un neurone linéaire s'écrit :
C'est exactement le calcul que fait
nn.Lineardans PyTorch, à la propagation automatique des gradients près.
Classe LinearNeuron
L'implémentation suit la même logique que la version 1D, en remplaçant le scalaire par le vecteur et le produit a * x par le produit matriciel X @ w. Une subtilité pratique : la dimension des entrées peut être déduite directement de la matrice au moment du premier fit. On peut donc initialiser self.w = None dans __init__ et créer le vecteur w de taille au début de fit :
class LinearNeuron: def __init__(self): self.w = None self.b = np.random.uniform() self.history = [] def forward(self, X): return X @ self.w + self.b def fit(self, X, y, lr=0.1, epochs=10, mode="batch", batch_size=32): n, m = X.shape self.w = np.random.uniform(size=m) for _ in range(epochs): if mode == "batch": idx = np.arange(n) elif mode == "sgd": idx = np.array([np.random.randint(n)]) elif mode == "minibatch": idx = np.random.choice(n, size=batch_size) else: raise ValueError("mode non reconnu") u = self.forward(X[idx]) grad_w = X[idx].T @ (u - y[idx]) / len(idx) grad_b = (u - y[idx]).mean() self.w -= lr * grad_w self.b -= lr * grad_b self.history.append(((y[idx] - u) ** 2).mean()) return self.history
Une utilisation typique sur abalone_mini consiste à séparer les données en train/test, puis à entraîner :
df = pd.read_csv('/kaggle/input/datasets/pyim59/mini-datasets/abalone_mini.csv') X = df.drop(columns='Rings').to_numpy() y = df['Rings'].to_numpy() X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2) model = LinearNeuron() model.fit(X_train, y_train, mode="minibatch", epochs=1000, lr=0.01) y_hat = model.forward(X_test)
Tant qu'on reste sur abalone_mini, où les variables sont toutes des dimensions de coquillage du même ordre de grandeur (centimètres, grammes), tout se passe correctement. Sur house_mini, en revanche, la situation est très différente : on y trouve des surfaces en centaines, des nombres de pièces en unités, des prix en centaines de milliers. Quand on essaie d'entraîner un LinearNeuron sur ces données brutes, la loss diverge presque immédiatement, ou n'avance qu'avec des learning rates extraordinairement petits. C'est l'occasion d'aborder une question fondamentale.
Pourquoi faut-il normaliser les entrées ?
L'expérience précédente n'est pas un détail technique : elle révèle un défaut structurel de la descente de gradient sur des entrées non normalisées. Pour le comprendre, regardons d'abord le cas le plus simple possible — un seul paramètre.
Le cas à une dimension
Considérons une fonction de coût quadratique d'un seul paramètre :
où est la valeur optimale et mesure la courbure de la fonction de coût. Le gradient vaut . La descente de gradient s'écrit :
En posant l'erreur , on obtient une récurrence très simple :
L'erreur est multipliée à chaque itération par le facteur . Pour que la descente converge, il faut que ce facteur soit strictement plus petit que 1 en valeur absolue :
La vitesse de convergence dépend directement de la courbure . Si est grand (fonction très raide), doit être très petit et la mise à jour ne progresse que lentement. Si est petit (fonction très plate), la convergence est intrinsèquement lente.
Le lien avec les données
Reprenons la régression linéaire à une entrée. Un calcul rapide montre que la courbure de par rapport à est proportionnelle à :
Si la variable prend des valeurs très grandes — par exemple des prix en euros avec des moyennes à 200 000 — alors est gigantesque et doit être tout petit pour ne pas diverger. Si à l'inverse prend des valeurs très petites, est tout petit et la convergence est très lente. Dans les deux cas, le mauvais réglage de l'échelle des entrées pénalise la descente.
Plusieurs paramètres : conditionnement du Hessien
Avec paramètres, la courbure n'est plus un scalaire mais une matrice Hessienne . Chaque direction de l'espace des paramètres a sa propre courbure (les valeurs propres de ), et la vitesse de convergence dépend du conditionnement . Plus ce ratio est grand, plus la fonction de coût ressemble à une vallée allongée et étroite, et plus la descente de gradient zigzague sans avancer dans la direction utile.
Lorsque les variables d'entrée n'ont pas la même échelle, leur poids respectif dans le Hessien est très différent et explose. Normaliser les entrées revient à égaliser approximativement les courbures dans toutes les directions, ce qui ramène le conditionnement vers 1 et accélère drastiquement la descente.
À retenir — Normaliser les entrées n'est pas un caprice cosmétique. C'est une condition pratique pour que la descente de gradient converge à une vitesse raisonnable, sans avoir à miniaturiser le learning rate. Sur un réseau profond, c'est encore plus critique.
Méthodes de normalisation avec scikit-learn
En pratique, on utilise les transformeurs de sklearn.preprocessing. Trois sont à connaître.
Le MinMaxScaler ramène chaque variable dans un intervalle borné, généralement :
Il conserve la forme de la distribution mais reste sensible aux valeurs aberrantes (un seul outlier change ). Il est utile lorsque les bornes ont un sens physique.
Le StandardScaler centre les données et les réduit à variance unitaire :
C'est le choix par défaut pour la descente de gradient et le deep learning. Il rend les variables comparables tout en restant simple à inverser.
Le RobustScaler utilise des statistiques robustes (médiane et écart interquartile) :
avec . Il est beaucoup moins sensible aux outliers et à privilégier lorsque les données contiennent des valeurs extrêmes.
Quel scaler choisir ?
StandardScalerpar défaut, et en deep learning toujours.MinMaxScalersi les bornes ont un sens physique ou si l'on veut des entrées dans .RobustScaleren présence d'outliers manifestes.
fit sur le train, transform ensuite
Un point essentiel : on ne normalise jamais l'ensemble du dataset d'un seul coup après l'avoir lu. La règle est d'abord de séparer train et test, puis de calculer les statistiques de normalisation uniquement sur le train, et enfin d'appliquer ces statistiques à X_train et à X_test :
from sklearn.preprocessing import StandardScaler X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test)
La distinction fit / transform est ici fondamentale :
fitcalcule les statistiques (moyenne, écart-type, min/max, etc.) à partir du jeu d'apprentissage,transformapplique ces statistiques à un jeu de données quelconque.
Pourquoi cette dissymétrie ? Le jeu de test doit rester totalement inconnu du modèle. Si l'on appelait
scaler.fit_transform(X)avant la séparation train/test, les statistiques de normalisation seraient calculées en partie à partir des données de test : c'est ce qu'on appelle une fuite d'information (data leakage). On utiliserait de l'information future pour entraîner le modèle, ce qui biaise l'évaluation des performances.
En appliquant StandardScaler à house_mini avant d'entraîner notre LinearNeuron, on observe deux changements spectaculaires : on peut utiliser un learning rate beaucoup plus grand (par exemple au lieu de ), et la convergence est plus rapide et plus stable. C'est l'illustration concrète de tout ce qui précède sur le conditionnement du Hessien.
Et la cible ?
En régression, il est souvent utile de normaliser également la cible , surtout lorsqu'elle prend des valeurs très grandes (prix, surfaces). Cela revient simplement à apprendre une régression sur la version centrée-réduite de ; à la prédiction, on inverse la transformation. PyTorch et sklearn ne font pas cette opération automatiquement, mais elle est fréquemment nécessaire et améliore souvent la convergence de manière significative.
Récapitulatif
Ce chapitre a posé les fondations du deep learning à partir du cas le plus élémentaire :
- Un neurone linéaire calcule — c'est exactement une régression linéaire, exprimée dans le vocabulaire des réseaux de neurones.
- L'apprentissage repose sur la descente de gradient : on minimise une fonction de coût (ici la MSE) en mettant à jour les paramètres dans la direction opposée au gradient, avec un learning rate qui contrôle l'amplitude des pas.
- Trois variantes : batch (gradient sur tout le dataset), SGD (gradient sur un seul exemple), minibatch (gradient sur exemples). Le minibatch est la stratégie standard, à la fois pour des raisons de coût et pour son effet régularisateur.
- L'extension au cas multi-entrées se fait naturellement en notation matricielle : la prédiction devient et le gradient .
- La normalisation des entrées est cruciale : elle améliore le conditionnement du Hessien, autorise des learning rates plus grands et stabilise la descente. On utilise
StandardScalerpar défaut, en faisantfitsur le train uniquement.
Tous ces ingrédients seront repris à l'identique dans les chapitres suivants, avec deux extensions majeures : la non-linéarité (fonctions d'activation) qui permet d'empiler des couches, et l'autograd des bibliothèques de deep learning, qui calcule les gradients automatiquement à partir du code du forward.
Exercices
Exercice 1 — Dériver
En partant de la perte par exemple
recalculer en utilisant la règle de dérivation en chaîne.
Puis écrire la version batch de , en moyennant sur .
Exercice 2 — Implémenter LinearNeuron1D
Implémenter une classe Python LinearNeuron1D correspondant à un neurone linéaire à une entrée. La classe doit contenir :
- une méthode
__init__qui initialise les paramètres et (valeurs aléatoires ou nulles) ; - une méthode
forward(x)qui calcule la prédiction , oùxest un vecteur de données ; - une méthode
fit(x, y, lr, epochs)qui entraîne le neurone par descente de gradient batch.
À chaque epoch, la méthode fit devra :
- calculer les prédictions ,
- calculer les gradients et ,
- mettre à jour les paramètres :
Tester la classe sur le jeu de données abalone_mini en prédisant Rings à partir de Length. Évaluer la qualité du modèle avec MAE et RMSE.
Exercice 3 — Mémoriser l'historique de la loss
Modifier la classe LinearNeuron1D afin de mémoriser l'évolution de la fonction de coût au cours de l'apprentissage.
Ajouter un attribut history, initialisé comme une liste vide dans __init__. À chaque epoch de la méthode fit, calculer la fonction de coût
puis ajouter sa valeur à la liste history (méthode append). Après l'entraînement, utiliser history pour tracer la courbe de la loss en fonction du nombre d'epochs.
Exercice 4 — Modes batch / SGD / minibatch
Étendre la méthode fit de LinearNeuron1D pour permettre différentes stratégies de descente de gradient.
Ajouter un paramètre mode à fit, pouvant prendre les valeurs "batch", "sgd" ou "minibatch" :
- en mode
"batch", le gradient est calculé sur l'ensemble des données (comportement actuel) ; - en mode
"sgd", à chaque mise à jour, un seul exemple est tiré aléatoirement ; - en mode
"minibatch", à chaque mise à jour, un sous-ensemble de taille est tiré aléatoirement et le gradient est moyenné sur ce minibatch.
Ajouter un paramètre batch_size utilisé uniquement en mode "minibatch". Rappels NumPy :
np.random.randint(0, n)tire un entier entre0etn-1;np.random.choice(n, size=B, replace=False)tireBindices distincts entre0etn-1;x[idx]extrait le sous-ensemble correspondant aux indicesidx.
Vérifier que, à learning rate et nombre d'epochs égaux, la trajectoire de la loss est plus bruitée en SGD qu'en batch, et que le minibatch constitue un compromis entre les deux. Tracer les trois courbes sur la même figure.
Exercice 5 — Implémenter LinearNeuron
Implémenter une classe Python LinearNeuron correspondant à un neurone linéaire à entrées multiples. Le neurone doit calculer la prédiction
où est une matrice de taille , un vecteur de poids de taille , et un biais scalaire.
La classe doit contenir une méthode __init__, une méthode forward(X) et une méthode fit(X, y, lr, epochs) qui entraîne par descente de gradient batch. Indication : la dimension peut être déduite de X.shape au premier appel à fit, donc self.w peut être initialisé à None dans __init__.
À chaque epoch, calculer :
- les prédictions ,
- la fonction de coût ,
- les gradients
- les mises à jour de et .
Tester sur abalone_mini en prédisant Rings à partir de toutes les autres variables explicatives. Comparer les performances (MAE, RMSE) avec celles du modèle 1D.
Exercice 6 — LinearNeuron sur house_mini
Tester la classe LinearNeuron sur le jeu de données house_mini, en prédisant la variable cible price à partir des autres colonnes numériques. Quelles sont vos observations ? Que se passe-t-il si vous gardez le learning rate par défaut ? Que faut-il faire pour rendre la descente stable ?
Exercice 7 — Effet de la normalisation
Sur le même jeu de données house_mini :
- Sans aucune normalisation, chercher un learning rate qui ne fasse pas diverger la descente. Constater qu'il doit être extrêmement petit, et que la convergence est lente.
- Normaliser ensuite
X_trainetX_testavec unStandardScaler(en faisant attention à ne pas commettre de fuite d'information :fituniquement sur le train). - Augmenter progressivement le learning rate (par exemple 0.01, 0.1, 0.5, 1.0) et observer l'effet sur la trajectoire de la loss et sur les performances finales.
Conclure sur l'effet de la normalisation : sur le learning rate utilisable, sur la vitesse de convergence et sur la qualité finale du modèle.
Pour aller plus loin
Calcul automatique des gradients. Toutes les bibliothèques modernes de deep learning (PyTorch, TensorFlow, JAX) reposent sur un mécanisme d'autograd : à partir du code Python du forward, elles construisent automatiquement le graphe des opérations et savent calculer le gradient de la loss par rapport à n'importe quel paramètre, sans qu'on ait à dériver à la main. C'est ce qui rend les réseaux profonds praticables : on ne calcule plus de gradient soi-même, on les laisse être déduits du code. En PyTorch, le neurone linéaire de ce chapitre se réduit à nn.Linear(m, 1) ; nous l'utiliserons à partir du chapitre suivant.
Méthodes d'optimisation plus sophistiquées. Au-delà de la descente de gradient simple, il existe des variantes qui accélèrent la convergence et stabilisent l'apprentissage : momentum (Polyak), Nesterov accelerated gradient, RMSProp, Adam, AdamW. Adam est aujourd'hui l'optimiseur par défaut de la plupart des projets de deep learning. Tous reposent sur le même principe que ce que nous avons codé, en y ajoutant une mémoire des gradients passés et un learning rate adaptatif.
Méthodes du second ordre. En dehors du deep learning, les méthodes qui exploitent la matrice Hessienne (Newton, BFGS, L-BFGS) convergent souvent en beaucoup moins d'itérations. Elles sont disponibles dans scipy.optimize. Elles deviennent impraticables quand le nombre de paramètres est très grand — d'où la prééminence des méthodes du premier ordre dans le deep learning moderne.
Lecture de référence. Pour une présentation complète et rigoureuse de tout ce qui suit, le livre incontournable est Deep Learning de Ian Goodfellow, Yoshua Bengio et Aaron Courville (MIT Press, 2016), librement accessible sur deeplearningbook.org. Les chapitres 4 (numerical computation) et 5 (machine learning basics) reprennent en détail la descente de gradient, le conditionnement et la normalisation.