Découvrez les réseaux de neurones
Comment un tri postal reconnaît-il un code postal écrit à la main ? Aucune règle simple (« un 7 a une barre horizontale en haut ») ne résiste à la variété des écritures. Les réseaux de neurones apprennent eux-mêmes les bonnes caractéristiques, couche après couche, à partir des pixels bruts. Ce sont eux qui sont derrière la reconnaissance d’images, la traduction automatique et les grands modèles de langage.
Ce chapitre vous en donne les bases solides : ce qu’est un neurone, comment un réseau apprend, et comment l’empêcher d’apprendre par cœur. Notre fil rouge : reconnaître des chiffres manuscrits dans de petites images de 8 × 8 pixels (le jeu digits, fourni avec scikit-learn : 1 797 images, 10 classes).
Du neurone au réseau
Section intitulée « Du neurone au réseau »Un neurone artificiel
Section intitulée « Un neurone artificiel »Un neurone artificiel fait deux choses : il calcule une somme pondérée de ses entrées, comme un modèle linéaire (chapitre 7), puis il applique une fonction non linéaire, dite fonction d’activation :
- est le vecteur d’entrée (ici, les 64 intensités de pixels) ;
- et sont les poids et le biais du neurone ;
- est la fonction d’activation, et la sortie (l’activation) du neurone.
Avec la sigmoïde comme activation, un neurone seul est une régression logistique. Rien de magique, donc : la puissance vient de l’assemblage.
Le perceptron multicouche
Section intitulée « Le perceptron multicouche »Un perceptron multicouche (MLP, multilayer perceptron) empile des couches de neurones. Chaque neurone d’une couche reçoit toutes les sorties de la couche précédente (couches denses). Avec une couche cachée :
- est la matrice des poids de la couche cachée (une ligne par neurone caché) et son vecteur de biais ;
- est la représentation intermédiaire calculée par la couche cachée ;
- , sont les paramètres de la couche de sortie, et son activation (adaptée à la tâche, voir plus bas).
Pour nos chiffres : 64 entrées, une couche cachée de 32 neurones et 10 sorties (une par chiffre). Le nombre de paramètres vaut (chaque neurone a un poids par entrée, plus un biais).
Un résultat théorique, le théorème d’approximation universelle, garantit qu’un MLP à une seule couche cachée, si elle est assez large, peut approcher n’importe quelle fonction continue sur un domaine borné. Il ne dit pas comment trouver les poids, ni combien de données il faut : en pratique, des réseaux plus profonds (plusieurs couches) apprennent plus facilement des représentations utiles.
Les fonctions d’activation, et pourquoi elles sont indispensables
Section intitulée « Les fonctions d’activation, et pourquoi elles sont indispensables »Pourquoi ne pas se passer de ? Bonne question ! Parce que l’enchaînement de deux couches linéaires reste linéaire : . Sans activation non linéaire, un réseau de cent couches n’est qu’une régression linéaire déguisée.
| Activation | Formule | Remarques |
|---|---|---|
| Sigmoïde | sortie entre 0 et 1 ; sature pour grand, le gradient s’évanouit | |
| Tangente hyperbolique | sortie entre -1 et 1, centrée ; sature aussi | |
| ReLU | le choix par défaut dans les couches cachées ; calcul rapide ; un neurone peut « mourir » s’il reste en zone négative |
La couche de sortie, elle, dépend de la tâche, et va de pair avec une fonction de perte :
| Tâche | Sortie | Perte |
|---|---|---|
| Classification binaire | 1 neurone sigmoïde | entropie croisée binaire |
| Classification multiclasse | neurones + softmax | entropie croisée |
| Multi-étiquette | neurones sigmoïdes | entropie croisée binaire par étiquette |
| Régression | 1 neurone sans activation | MSE |
Apprendre : descente de gradient et rétropropagation
Section intitulée « Apprendre : descente de gradient et rétropropagation »La descente de gradient
Section intitulée « La descente de gradient »Apprendre, c’est trouver les poids qui minimisent la perte sur les données d’apprentissage. Il n’y a pas de formule exacte : on procède par petits pas. Le gradient indique la direction dans laquelle la perte augmente le plus ; on fait donc un pas dans la direction opposée :
où est le taux d’apprentissage (learning rate). Trop grand, les pas enjambent le minimum et l’apprentissage diverge ; trop petit, il faut une éternité.
Sur quelles données calcule-t-on ce gradient ?
- Descente de gradient classique (batch) : sur tout le jeu. Précise, mais chaque pas est coûteux.
- Descente de gradient stochastique (SGD) : sur un seul exemple à la fois. Très rapide, mais bruitée (ce bruit aide parfois à sortir d’un mauvais minimum local).
- Mini-lots (mini-batches) : sur un petit paquet d’exemples (32, 64, 128…). C’est le compromis standard, qui exploite bien le calcul parallèle des cartes graphiques.
Un passage complet sur le jeu d’apprentissage s’appelle une époque (epoch). Des variantes comme Adam adaptent automatiquement le pas pour chaque poids.
La rétropropagation
Section intitulée « La rétropropagation »Dans un réseau, la perte dépend des poids de la dernière couche, qui dépendent des sorties de la couche précédente, et ainsi de suite. La rétropropagation (backpropagation) calcule tous les gradients efficacement grâce à la règle de dérivation en chaîne, en partant de la sortie et en remontant couche par couche :
- propagation avant : calculer les activations de chaque couche, puis la perte ;
- propagation arrière : calculer le gradient de la perte par rapport aux sorties de la dernière couche, puis en déduire celui de la couche précédente, et ainsi de suite ;
- mise à jour : appliquer la descente de gradient à tous les poids.
Dans un vrai réseau, on n’écrit jamais ces dérivées à la main : les bibliothèques comme PyTorch les calculent automatiquement (différentiation automatique).
Lutter contre le surapprentissage
Section intitulée « Lutter contre le surapprentissage »Avec des milliers, voire des milliards de paramètres, un réseau peut apprendre par cœur n’importe quel jeu d’apprentissage. Les remèdes classiques :
- Arrêt précoce (early stopping) : suivre la perte sur un jeu de validation à chaque époque, et arrêter quand elle remonte, en gardant les meilleurs poids.
- Dropout : pendant l’apprentissage, éteindre au hasard une fraction des neurones (par exemple 20 %) à chaque passage. Le réseau ne peut plus compter sur un neurone particulier et apprend des représentations plus robustes. Au moment de prédire, tous les neurones sont actifs.
- Pénalité L2 sur les poids (weight decay), comme pour Ridge au chapitre 7.
- Plus de données, y compris par augmentation : légères rotations ou translations d’images, par exemple.
- Un réseau plus petit.
Et n’oubliez pas de normaliser les entrées : comme les k-NN et les SVM, les réseaux apprennent mal quand les variables ont des échelles très différentes.
Un aperçu des architectures spécialisées
Section intitulée « Un aperçu des architectures spécialisées »Les MLP traitent chaque entrée comme une case indépendante. Pour certains types de données, des architectures exploitent leur structure :
- Les réseaux convolutifs (CNN) sont faits pour les images. Une couche de convolution fait glisser de petits filtres (3 × 3 pixels, par exemple) sur toute l’image : un même filtre détecte un motif (un bord, un coin) où qu’il se trouve, avec très peu de poids (partage des poids). Des couches de pooling réduisent ensuite la résolution. Les premières couches détectent des bords, les suivantes des formes, les dernières des objets.
- Les réseaux récurrents (RNN) traitent des séquences (texte, signal, séries temporelles) : ils lisent les éléments un par un en mettant à jour un état caché qui sert de mémoire. Les LSTM ajoutent des « portes » (d’oubli, d’entrée, de sortie) qui leur permettent de retenir des dépendances lointaines. Pour le texte, ils ont depuis été largement remplacés par les transformeurs, fondés sur un mécanisme d’attention.
- Les auto-encodeurs apprennent à compresser une donnée puis à la reconstruire ; la représentation compressée sert à réduire la dimension ou à débruiter.
Une boucle d’entraînement en PyTorch
Section intitulée « Une boucle d’entraînement en PyTorch »Voici un MLP complet sur les chiffres manuscrits, avec une boucle d’entraînement par mini-lots. C’est la structure de base de presque tous les entraînements en PyTorch.
import torchfrom torch import nnfrom torch.utils.data import DataLoader, TensorDatasetfrom sklearn.datasets import load_digitsfrom sklearn.model_selection import train_test_split
X, y = load_digits(return_X_y=True) # 1 797 images de 8 × 8 pixels, valeurs de 0 à 16X = X / 16.0 # mise à l'échelle entre 0 et 1X_app, X_val, y_app, y_val = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)
def en_tenseurs(X, y): return torch.tensor(X, dtype=torch.float32), torch.tensor(y, dtype=torch.long)
X_app_t, y_app_t = en_tenseurs(X_app, y_app)X_val_t, y_val_t = en_tenseurs(X_val, y_val)lots = DataLoader(TensorDataset(X_app_t, y_app_t), batch_size=32, shuffle=True)
torch.manual_seed(0)modele = nn.Sequential( nn.Linear(64, 32), nn.ReLU(), nn.Dropout(0.2), # couche cachée nn.Linear(32, 10), # une sortie par chiffre)perte = nn.CrossEntropyLoss() # applique elle-même la softmaxoptimiseur = torch.optim.Adam(modele.parameters(), lr=1e-3)
for epoque in range(1, 51): modele.train() # active le dropout for xb, yb in lots: optimiseur.zero_grad() # remet les gradients à zéro l = perte(modele(xb), yb) # propagation avant et perte l.backward() # rétropropagation optimiseur.step() # un pas de descente de gradient if epoque % 10 == 0: modele.eval() # désactive le dropout with torch.no_grad(): exactitude = (modele(X_val_t).argmax(dim=1) == y_val_t).float().mean().item() print(f"époque {epoque} : exactitude en validation {exactitude:.3f}")Trois détails comptent : zero_grad() avant chaque lot (sinon les gradients s’additionnent d’un lot à l’autre), train() et eval() pour activer ou non le dropout, et torch.no_grad() pendant l’évaluation pour ne pas calculer de gradients inutiles. Pour un arrêt précoce, il suffirait de garder une copie des poids à la meilleure exactitude de validation.
En résumé
Section intitulée « En résumé »- Un neurone calcule ; un MLP empile des couches de neurones, et doit sa puissance aux activations non linéaires (ReLU dans les couches cachées).
- La couche de sortie et la perte dépendent de la tâche : sigmoïde et entropie croisée binaire, softmax et entropie croisée, sortie linéaire et MSE.
- On apprend par descente de gradient, le plus souvent par mini-lots ; la rétropropagation calcule les gradients couche par couche grâce à la règle de la chaîne.
- Le taux d’apprentissage doit être ni trop grand (divergence) ni trop petit (lenteur).
- Contre le surapprentissage : arrêt précoce, dropout, pénalité L2, augmentation de données.
- Les CNN exploitent la structure des images (filtres partagés), les RNN et LSTM celle des séquences.
Exercices
Section intitulée « Exercices »Exercice · Compter les paramètres
facileUn MLP pour les iris a 4 entrées, une couche cachée de 8 neurones et 3 sorties. Combien a-t-il de paramètres ? Et si l’on ajoute une seconde couche cachée de 8 neurones ?
Voir la solution
Chaque neurone a un poids par entrée plus un biais.
- Couche cachée : paramètres.
- Couche de sortie : paramètres.
Total : 67 paramètres. Avec une seconde couche cachée de 8 neurones, on ajoute paramètres entre les deux couches cachées, soit 139 au total.
En code
import numpy as npfrom sklearn.datasets import load_irisfrom sklearn.neural_network import MLPClassifier
def n_parametres(tailles): entrees, sorties = np.array(tailles[:-1]), np.array(tailles[1:]) return int(np.sum(sorties * (entrees + 1))) # un poids par entrée + un biais, par neurone
print(n_parametres([4, 8, 3]), n_parametres([4, 8, 8, 3])) # 67 139
# Vérification sur un vrai MLP entraîné sur les irisX, y = load_iris(return_X_y=True)for cachees in [(8,), (8, 8)]: mlp = MLPClassifier(hidden_layer_sizes=cachees, max_iter=2000, random_state=0).fit(X, y) print(sum(W.size for W in mlp.coefs_) + sum(b.size for b in mlp.intercepts_)) # 67 puis 139n_parametres <- function(tailles) { entrees <- head(tailles, -1); sorties <- tail(tailles, -1) sum(sorties * (entrees + 1)) # un poids par entrée, plus un biais, par neurone}
n_parametres(c(4, 8, 3)) # 67n_parametres(c(4, 8, 8, 3)) # 139Exercice · Pourquoi des activations ?
moyenMontrez qu’un réseau de deux couches sans fonction d’activation, , est équivalent à un modèle linéaire à une seule couche. Qu’en déduisez-vous sur un réseau de dix couches sans activation ?
Voir la solution
En développant : . Posons et : on obtient , un modèle linéaire à une seule couche. Le produit de matrices et la somme de vecteurs restent une matrice et un vecteur.
Par récurrence, un réseau de dix couches sans activation se réduit aussi à une seule couche linéaire : il ne peut représenter que des frontières linéaires, quelle que soit sa profondeur. Ce sont les activations non linéaires (ReLU, tanh…) entre les couches qui donnent aux réseaux leur capacité à représenter des fonctions complexes.
En code
import numpy as np
rng = np.random.default_rng(0)W1, b1 = rng.normal(size=(5, 3)), rng.normal(size=5) # couche 1 : 3 entrées, 5 neuronesW2, b2 = rng.normal(size=(2, 5)), rng.normal(size=2) # couche 2 : 2 sortiesx = rng.normal(size=3)
deux_couches = W2 @ (W1 @ x + b1) + b2W, b = W2 @ W1, W2 @ b1 + b2 # la couche unique équivalenteprint(np.allclose(deux_couches, W @ x + b)) # True
# Avec une ReLU entre les deux, plus aucune couche unique ne convient en généralavec_relu = W2 @ np.maximum(0, W1 @ x + b1) + b2print(np.allclose(avec_relu, W @ x + b)) # Falseset.seed(0)W1 <- matrix(rnorm(15), 5, 3); b1 <- rnorm(5) # couche 1 : 3 entrées, 5 neuronesW2 <- matrix(rnorm(10), 2, 5); b2 <- rnorm(2) # couche 2 : 2 sortiesx <- rnorm(3)
deux_couches <- W2 %*% (W1 %*% x + b1) + b2W <- W2 %*% W1; b <- W2 %*% b1 + b2 # la couche unique équivalenteall.equal(deux_couches, W %*% x + b) # TRUE
# Avec une ReLU entre les deux, plus aucune couche unique ne convient en généralavec_relu <- W2 %*% pmax(0, W1 %*% x + b1) + b2isTRUE(all.equal(avec_relu, W %*% x + b)) # FALSEExercice · Un pas de gradient à la main
difficileUn neurone sigmoïde a les poids et le biais . On lui présente l’entrée , d’étiquette , avec la perte d’entropie croisée binaire et un taux d’apprentissage .
- Calculez la prédiction et la perte.
- Calculez les gradients et les nouveaux paramètres.
- Vérifiez que la nouvelle prédiction s’est rapprochée de l’étiquette.
Voir la solution
- , donc . Comme , la perte vaut .
- La dérivée par rapport à vaut . D’où et . Mise à jour : et .
- Nouveau score : , donc , et la perte tombe à environ . La prédiction est passée de 0,525 à 0,186 : elle s’est nettement rapprochée de l’étiquette 0.
En code
import numpy as np
sigmoide = lambda z: 1 / (1 + np.exp(-z))perte = lambda y_hat, y: -(y * np.log(y_hat) + (1 - y) * np.log(1 - y_hat))
w, b = np.array([0.1, 0.3]), 0.2x, y, eta = np.array([2.0, -1.0]), 0, 0.5
# 1. Propagation avanty_hat = sigmoide(w @ x + b)print(round(y_hat, 3), round(perte(y_hat, y), 3)) # 0.525 0.744
# 2. Gradients (dL/dz = y_hat - y) et mise à jourgrad_w, grad_b = (y_hat - y) * x, (y_hat - y)w, b = w - eta * grad_w, b - eta * grad_bprint(grad_w.round(3), w.round(3), round(b, 3)) # [1.05 -0.525] [-0.425 0.562] -0.062
# 3. Nouvelle prédictiony_hat = sigmoide(w @ x + b)print(round(y_hat, 3), round(perte(y_hat, y), 3)) # 0.186 0.206sigmoide <- function(z) 1 / (1 + exp(-z))perte <- function(y_hat, y) -(y * log(y_hat) + (1 - y) * log(1 - y_hat))
w <- c(0.1, 0.3); b <- 0.2x <- c(2, -1); y <- 0; eta <- 0.5
# 1. Propagation avanty_hat <- sigmoide(sum(w * x) + b)round(c(y_hat, perte(y_hat, y)), 3) # 0.525 0.744
# 2. Gradients (dL/dz = y_hat - y) et mise à jourgrad_w <- (y_hat - y) * x; grad_b <- y_hat - yw <- w - eta * grad_w; b <- b - eta * grad_bround(c(w, b), 3) # -0.425 0.562 -0.062
# 3. Nouvelle prédictiony_hat <- sigmoide(sum(w * x) + b)round(c(y_hat, perte(y_hat, y)), 3) # 0.186 0.206