Aller au contenu

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

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 :

a=φ(w⊤x+b)a = \varphi\big(w^\top x + b\big)
  • xx est le vecteur d’entrée (ici, les 64 intensités de pixels) ;
  • ww et bb sont les poids et le biais du neurone ;
  • φ\varphi est la fonction d’activation, et aa 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.

w1w2wd⋮z = wᵀx + bactivationa = φ(z)entrées (64 pixels)poidsx1x2xdΣ + bφ
Un neurone, c'est un modèle linéaire suivi d'une non-linéarité : la somme pondérée des entrées, puis la fonction d'activation.

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 :

h=φ(W1x+b1),y^=ψ(W2h+b2)h = \varphi(W_1 x + b_1), \qquad \hat{y} = \psi(W_2 h + b_2)
  • W1W_1 est la matrice des poids de la couche cachée (une ligne par neurone caché) et b1b_1 son vecteur de biais ;
  • hh est la représentation intermédiaire calculée par la couche cachée ;
  • W2W_2, b2b_2 sont les paramètres de la couche de sortie, et ψ\psi 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 32×(64+1)+10×(32+1)=2 080+330=2 41032 \times (64 + 1) + 10 \times (32 + 1) = 2\,080 + 330 = 2\,410 (chaque neurone a un poids par entrée, plus un biais).

⋮entrée64 pixels⋮couche cachée32 neurones, ReLU⋮sortie10 chiffres, softmaxW₁ : 32 × (64 + 1) = 2 080 paramètresW₂ : 10 × (32 + 1) = 330
Le réseau du fil rouge, dessiné en partie (les points de suspension cachent les autres neurones). Chaque trait est un poids ; avec les biais, 2 410 paramètres au total.

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 φ\varphi ? Bonne question ! Parce que l’enchaînement de deux couches linéaires reste linéaire : W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)W_2(W_1 x + b_1) + b_2 = (W_2 W_1) x + (W_2 b_1 + b_2). Sans activation non linéaire, un réseau de cent couches n’est qu’une régression linéaire déguisée.

ActivationFormuleRemarques
Sigmoïdeσ(z)=11+e−z\sigma(z) = \frac{1}{1 + e^{-z}}sortie entre 0 et 1 ; sature pour ∣z∣\lvert z \rvert grand, le gradient s’évanouit
Tangente hyperboliquetanh⁡(z)\tanh(z)sortie entre -1 et 1, centrée ; sature aussi
ReLUmax⁡(0,z)\max(0, z)le choix par défaut dans les couches cachées ; calcul rapide ; un neurone peut « mourir » s’il reste en zone négative
−5,0−2,50,02,55,0z0,00,20,40,60,81,0saturation :gradient ≈ 0sigmoïdeφ(z)dérivée φ′(z)−5,0−2,50,02,55,0z−1,0−0,50,00,51,0saturation :gradient ≈ 0tanh−5,0−2,50,02,55,0z012345gradient nulpour z < 0pente 1ReLU
En pointillés, la dérivée, c'est-à-dire ce que la rétropropagation fait passer. Sigmoïde et tanh saturent : loin de 0, le gradient s'évanouit. ReLU garde une pente de 1 partout où elle est active.

La couche de sortie, elle, dépend de la tâche, et va de pair avec une fonction de perte :

TâcheSortiePerte
Classification binaire1 neurone sigmoïdeentropie croisée binaire
Classification multiclasseKK neurones + softmaxentropie croisée
Multi-étiquetteKK neurones sigmoïdesentropie croisée binaire par étiquette
Régression1 neurone sans activationMSE

Apprendre : descente de gradient et rétropropagation

Section intitulée « Apprendre : descente de gradient et rétropropagation »

Apprendre, c’est trouver les poids qui minimisent la perte LL sur les données d’apprentissage. Il n’y a pas de formule exacte : on procède par petits pas. Le gradient ∇L\nabla L indique la direction dans laquelle la perte augmente le plus ; on fait donc un pas dans la direction opposée :

w←w−η ∂L∂ww \leftarrow w - \eta \, \frac{\partial L}{\partial w}

où η\eta est le taux d’apprentissage (learning rate). Trop grand, les pas enjambent le minimum et l’apprentissage diverge ; trop petit, il faut une éternité.

−202poids w012345perte L(w) = w²8 pas : encore loinη = 0,05 : trop petit−202poids wη = 0,30 : bien choisi−202poids wles pas enjambentle minimum et divergentη = 1,05 : trop grand
La même perte, trois taux d'apprentissage. Sur L(w) = w², un pas multiplie w par (1 − 2η) : au-delà de η = 1, chaque pas repart plus loin de l'autre côté.

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.
−10123poids w−2−10123biais bminimumdépartbatch (200 exemples)mini-lots de 16SGD (1 exemple)
Moins le gradient utilise d'exemples, moins chaque pas coûte, mais plus sa direction est bruitée (régression simulée à deux paramètres).

Un passage complet sur le jeu d’apprentissage s’appelle une époque (epoch). Des variantes comme Adam adaptent automatiquement le pas pour chaque poids.

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 :

  1. propagation avant : calculer les activations de chaque couche, puis la perte ;
  2. 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 ;
  3. 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).

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.
020406080100époqueperteon garde les poidsde l’époque 36le réseau apprend le bruitperte d’apprentissageperte de validation
L'arrêt précoce garde les poids de l'époque où la perte de validation est minimale ; au-delà, l'écart qui se creuse avec la perte d'apprentissage signale le surapprentissage (courbes schématiques).

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.

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.

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 torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.datasets import load_digits
from 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 à 16
X = X / 16.0 # mise à l'échelle entre 0 et 1
X_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 softmax
optimiseur = 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.

  • Un neurone calcule φ(w⊤x+b)\varphi(w^\top x + b) ; 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.

Exercice · Compter les paramètres

facile

Un 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 : 8×(4+1)=408 \times (4 + 1) = 40 paramètres.
  • Couche de sortie : 3×(8+1)=273 \times (8 + 1) = 27 paramètres.

Total : 67 paramètres. Avec une seconde couche cachée de 8 neurones, on ajoute 8×(8+1)=728 \times (8 + 1) = 72 paramètres entre les deux couches cachées, soit 139 au total.

En code

import numpy as np
from sklearn.datasets import load_iris
from 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 iris
X, 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 139

Exercice · Pourquoi des activations ?

moyen

Montrez qu’un réseau de deux couches sans fonction d’activation, y^=W2(W1x+b1)+b2\hat{y} = W_2 (W_1 x + b_1) + b_2, 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 : y^=W2W1x+W2b1+b2\hat{y} = W_2 W_1 x + W_2 b_1 + b_2. Posons W=W2W1W = W_2 W_1 et b=W2b1+b2b = W_2 b_1 + b_2 : on obtient y^=Wx+b\hat{y} = W x + b, 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 neurones
W2, b2 = rng.normal(size=(2, 5)), rng.normal(size=2) # couche 2 : 2 sorties
x = rng.normal(size=3)
deux_couches = W2 @ (W1 @ x + b1) + b2
W, b = W2 @ W1, W2 @ b1 + b2 # la couche unique équivalente
print(np.allclose(deux_couches, W @ x + b)) # True
# Avec une ReLU entre les deux, plus aucune couche unique ne convient en général
avec_relu = W2 @ np.maximum(0, W1 @ x + b1) + b2
print(np.allclose(avec_relu, W @ x + b)) # False

Exercice · Un pas de gradient à la main

difficile

Un neurone sigmoïde a les poids w=(0,1;0,3)w = (0{,}1 ; 0{,}3) et le biais b=0,2b = 0{,}2. On lui présente l’entrée x=(2,−1)x = (2, -1), d’étiquette y=0y = 0, avec la perte d’entropie croisée binaire et un taux d’apprentissage η=0,5\eta = 0{,}5.

  1. Calculez la prédiction y^\hat{y} et la perte.
  2. Calculez les gradients et les nouveaux paramètres.
  3. Vérifiez que la nouvelle prédiction s’est rapprochée de l’étiquette.
Voir la solution
  1. z=0,1×2+0,3×(−1)+0,2=0,1z = 0{,}1 \times 2 + 0{,}3 \times (-1) + 0{,}2 = 0{,}1, donc y^=σ(0,1)≈0,525\hat{y} = \sigma(0{,}1) \approx 0{,}525. Comme y=0y = 0, la perte vaut −ln⁡(1−y^)≈−ln⁡(0,475)≈0,744-\ln(1 - \hat{y}) \approx -\ln(0{,}475) \approx 0{,}744.
  2. La dérivée par rapport à zz vaut y^−y≈0,525\hat{y} - y \approx 0{,}525. D’où ∂L/∂w=0,525×(2,−1)=(1,050;−0,525)\partial L / \partial w = 0{,}525 \times (2, -1) = (1{,}050 ; -0{,}525) et ∂L/∂b=0,525\partial L / \partial b = 0{,}525. Mise à jour : w←(0,1−0,5×1,050;0,3+0,5×0,525)≈(−0,425;0,562)w \leftarrow (0{,}1 - 0{,}5 \times 1{,}050 ; 0{,}3 + 0{,}5 \times 0{,}525) \approx (-0{,}425 ; 0{,}562) et b←0,2−0,5×0,525≈−0,062b \leftarrow 0{,}2 - 0{,}5 \times 0{,}525 \approx -0{,}062.
  3. Nouveau score : z≈−0,425×2−0,562−0,062≈−1,475z \approx -0{,}425 \times 2 - 0{,}562 - 0{,}062 \approx -1{,}475, donc y^≈0,186\hat{y} \approx 0{,}186, et la perte tombe à environ 0,2060{,}206. 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.2
x, y, eta = np.array([2.0, -1.0]), 0, 0.5
# 1. Propagation avant
y_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 à jour
grad_w, grad_b = (y_hat - y) * x, (y_hat - y)
w, b = w - eta * grad_w, b - eta * grad_b
print(grad_w.round(3), w.round(3), round(b, 3)) # [1.05 -0.525] [-0.425 0.562] -0.062
# 3. Nouvelle prédiction
y_hat = sigmoide(w @ x + b)
print(round(y_hat, 3), round(perte(y_hat, y), 3)) # 0.186 0.206