Deep Learning
Comprendre ce qui se passe réellement dans un réseau.
Deep Learning et réseaux de neurones
L’expression « réseau de neurones » a de quoi intimider, et le vocabulaire biologique n’aide pas : il suggère une machinerie mystérieuse, vaguement cérébrale, hors de portée sans doctorat en mathématiques. La réalité est plus modeste et beaucoup plus intéressante. Un réseau de neurones est un empilement de multiplications, d’additions et d’une fonction simple appliquée au résultat. Rien de plus.
Ce qui mérite d’être compris n’est donc pas la formule d’un neurone — elle tient sur une ligne — mais deux choses moins évidentes. Pourquoi faut-il empiler plusieurs couches, alors qu’une seule suffirait si le monde était simple ? Et comment une machine peut-elle trouver seule des millions de paramètres, sans que personne ne lui indique la direction ?
La réponse à la seconde question s’appelle la rétropropagation, et elle est régulièrement présentée soit comme une évidence, soit comme un mur d’équations. Nous allons prendre une troisième voie : l’écrire entièrement en une trentaine de lignes de NumPy, sur un problème que nous pourrons vérifier à l’œil nu, et regarder l’erreur diminuer pas à pas.
À la fin de cette leçon, vous aurez construit un réseau qui apprend, sans bibliothèque d’apprentissage profond, et vous saurez exactement ce que chaque ligne accomplit.
Un neurone : une somme pondérée et une décision
Un neurone artificiel prend plusieurs nombres en entrée, les multiplie chacun par un poids, additionne le tout, ajoute un biais, puis fait passer le résultat dans une fonction. C’est tout.
import numpy as np
def neurone(entrees, poids, biais):
somme = float(np.dot(entrees, poids) + biais)
return 1 if somme > 0 else 0 # activation : simple seuil
# Un neurone qui calcule le ET logique.
poids = np.array([1.0, 1.0])
biais = -1.5
for x in ([0, 0], [0, 1], [1, 0], [1, 1]):
print(x, neurone(np.array(x, dtype=float), poids, biais))
# [0, 0] 0
# [0, 1] 0
# [1, 0] 0
# [1, 1] 1
L’arithmétique se vérifie de tête : avec deux entrées à 1, la somme vaut 1 + 1 - 1,5 = 0,5, positive, donc la sortie est 1. Avec une seule entrée à 1, la somme vaut 1 - 1,5 = -0,5, négative, donc 0. Le biais joue exactement le rôle d’un seuil de déclenchement : il faut dépasser 1,5 pour activer le neurone.
Ce dispositif est le perceptron, proposé par Frank Rosenblatt en 1957. Ici les poids ont été posés à la main ; l’apprentissage consistera à les faire trouver par la machine.
Pourquoi empiler des couches
Un neurone unique trace une frontière droite dans l’espace des entrées. Tant que les classes sont séparables par une droite, cela suffit. Le problème est qu’un cas aussi élémentaire que le OU exclusif ne l’est pas.
x2
│
1 │ ●(0,1) ○(1,1) ● = classe 1
│ ○ = classe 0
│
0 │ ○(0,0) ●(1,0)
└──────────────────────────── x1
0 1
Aucune droite ne peut mettre les deux ● d'un côté
et les deux ○ de l'autre.
Ce n’est pas une intuition, cela se mesure :
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([0, 1, 1, 0]) # OU exclusif
lineaire = LogisticRegression().fit(X, y)
print(lineaire.score(X, y), lineaire.predict(X)) # 0.5 [0 0 0 0]
reseau = MLPClassifier(hidden_layer_sizes=(4,), activation="tanh",
max_iter=5000, random_state=1).fit(X, y)
print(reseau.score(X, y), reseau.predict(X)) # 1.0 [0 1 1 0]
Le modèle linéaire obtient 0,5 — le score du hasard sur un problème équilibré — et prédit la même classe pour les quatre points : il n’a rien pu faire. Le réseau à une couche cachée de quatre neurones obtient 1,0.
Le mécanisme est le suivant : la couche cachée transforme l’espace d’entrée. Dans le nouvel espace qu’elle produit, les quatre points sont devenus séparables par une droite, et la couche de sortie n’a plus qu’à tracer cette droite. Empiler des couches, c’est enchaîner des changements de représentation jusqu’à ce que le problème devienne simple.
Ce réseau ne comporte que 17 paramètres : 2 × 4 poids d’entrée, 4 biais cachés, 4 × 1 poids de sortie, 1 biais de sortie. Un réseau moderne applique le même principe avec plusieurs ordres de grandeur de plus.
Les fonctions d’activation
Le seuil du perceptron a un défaut fatal pour l’apprentissage : sa pente est nulle partout, sauf en un point où elle est infinie. Impossible d’en tirer une direction de correction. On le remplace donc par des fonctions lisses.
import numpy as np
z = np.array([-2.0, -0.5, 0.0, 0.5, 2.0])
print(np.maximum(0, z)) # [0. 0. 0. 0.5 2. ] ReLU
print((1 / (1 + np.exp(-z))).round(3)) # [0.119 0.378 0.5 0.622 0.881] sigmoïde
print(np.tanh(z).round(3)) # [-0.964 -0.462 0. 0.462 0.964] tanh
| Fonction | Sortie | Usage courant | Point de vigilance |
|---|---|---|---|
ReLU max(0, z) | 0 ou z | couches cachées, par défaut | un neurone bloqué à 0 ne se corrige plus |
| Sigmoïde | entre 0 et 1 | sortie d’une classification binaire | pente quasi nulle aux extrêmes |
| tanh | entre −1 et 1 | couches cachées, petits réseaux | même saturation que la sigmoïde |
Le rôle de ces fonctions est souvent mal compris. Elles ne sont pas là pour « normaliser » : elles sont là pour introduire de la non-linéarité. Sans elles, empiler dix couches de multiplications et d’additions produirait… une multiplication et une addition. La composition de fonctions linéaires est linéaire, donc un réseau sans activation, quelle que soit sa profondeur, resterait aussi impuissant sur le OU exclusif que la régression logistique ci-dessus.
La descente de gradient : corriger dans le bon sens
Reste la question centrale : comment trouver les poids ? L’idée est de mesurer l’erreur, de calculer dans quel sens chaque paramètre doit bouger pour la réduire, et de faire un petit pas dans ce sens. Puis de recommencer.
Reprenons les loyers de la leçon précédente, mais sans LinearRegression : cette fois nous écrivons l’apprentissage.
import numpy as np
x = np.array([30.0, 45.0, 60.0, 75.0, 90.0])
y = np.array([620.0, 780.0, 1010.0, 1150.0, 1400.0])
# Mise à l'échelle des entrées : indispensable, on verra pourquoi juste après.
xn = (x - x.mean()) / x.std()
a, b = 0.0, 0.0
pas = 0.1
for etape in range(1, 201):
prediction = a * xn + b
erreur = prediction - y
perte = (erreur ** 2).mean() # erreur quadratique moyenne
# Dérivées de la perte par rapport à a et b.
a -= pas * 2 * (erreur * xn).mean()
b -= pas * 2 * erreur.mean()
if etape in (1, 10, 50, 200):
print(etape, round(perte, 1), round(a, 2), round(b, 2))
# 1 1059080.0 54.59 198.4
# 10 19587.4 243.64 885.48
# 50 518.0 272.94 991.99
# 200 518.0 272.94 992.0
La perte passe de plus d’un million à 518 et s’y stabilise. Deux vérifications montrent que le résultat n’est pas approximatif :
- Le biais converge vers 992,0, qui est exactement la moyenne des cinq loyers :
(620 + 780 + 1010 + 1150 + 1400) / 5 = 992. - La pente converge vers 272,94 dans l’espace mis à l’échelle. En la ramenant aux mètres carrés, on divise par l’écart-type des surfaces, soit 21,213 :
272,94 / 21,213 ≈ 12,86€ par m². C’est le coefficient queLinearRegressionavait trouvé dans la leçon précédente, obtenu ici par tâtonnement guidé.
La mise à l’échelle n’est pas cosmétique. Avec les surfaces brutes et le même pas de 0,1, la descente diverge : la perte passe de 1,06 × 10⁶ à 6,9 × 10¹¹ dès la deuxième itération, puis explose. Le gradient est proportionnel à l’amplitude des entrées ; des entrées autour de 60 produisent des pas gigantesques qui dépassent le minimum au lieu de s’en approcher. C’est la première cause d’échec d’un apprentissage qui « ne converge pas ».
perte
│╲
│ ╲ pas trop petit : correct mais lent
│ ╲___
│ ╲___
│ ╲________
└────────────────────► itérations
perte
│ ╱╲ ╱╲
│ ╱ ╲ ╱ ╲ pas trop grand : oscille ou explose
│ ╱ ╲╱ ╲
└────────────────────► itérations
La rétropropagation, sans formalisme
Sur un modèle à deux paramètres, on écrit les dérivées à la main. Sur un réseau, un poids de la première couche influence la sortie à travers toutes les couches suivantes. La rétropropagation est la façon d’organiser ce calcul : on propage l’erreur de la sortie vers l’entrée, couche par couche, en réutilisant à chaque étape ce qui a déjà été calculé.
Voici un réseau complet — deux entrées, quatre neurones cachés, une sortie — écrit sans aucune bibliothèque d’apprentissage profond.
import numpy as np
rng = np.random.default_rng(1)
X = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
y = np.array([[0.0], [1.0], [1.0], [0.0]]) # OU exclusif
# Initialisation aléatoire : deux poids identiques recevraient toujours
# la même correction et resteraient identiques à jamais.
W1 = rng.normal(0, 1, (2, 4))
b1 = np.zeros((1, 4))
W2 = rng.normal(0, 1, (4, 1))
b2 = np.zeros((1, 1))
def sigmoide(z):
return 1 / (1 + np.exp(-z))
for etape in range(1, 5001):
# --- Passe avant : de l'entrée vers la prédiction ---
z1 = X @ W1 + b1
a1 = np.tanh(z1)
z2 = a1 @ W2 + b2
a2 = sigmoide(z2)
perte = float(np.mean((a2 - y) ** 2))
# --- Passe arrière : de l'erreur vers les poids ---
# d2 : sensibilité de la perte à z2. Le facteur a2*(1-a2) est la
# dérivée de la sigmoïde ; il traduit « de combien la sortie bouge
# si z2 bouge un peu ».
d2 = 2 * (a2 - y) / len(X) * a2 * (1 - a2)
dW2 = a1.T @ d2
db2 = d2.sum(axis=0, keepdims=True)
# On remonte d'une couche : l'erreur est redistribuée aux neurones
# cachés proportionnellement à leur poids W2, puis multipliée par
# la dérivée de tanh, qui vaut 1 - tanh².
d1 = (d2 @ W2.T) * (1 - a1 ** 2)
dW1 = X.T @ d1
db1 = d1.sum(axis=0, keepdims=True)
# --- Mise à jour ---
for param, gradient in ((W1, dW1), (b1, db1), (W2, dW2), (b2, db2)):
param -= 0.5 * gradient
if etape in (1, 100, 1000, 5000):
print(etape, round(perte, 4))
# 1 0.24
# 100 0.1182
# 1000 0.0018
# 5000 0.0003
print(a2.round(3).ravel()) # [0.007 0.983 0.983 0.021]
Le réseau part d’une erreur de 0,24 et descend à 0,0003. Les quatre sorties finales — 0,007 / 0,983 / 0,983 / 0,021 — sont à comparer aux cibles 0 / 1 / 1 / 0. Le OU exclusif est résolu, par un programme dont toute la logique d’apprentissage tient en huit lignes.
Trois observations valent d’être retenues :
- La passe arrière est le miroir de la passe avant.
X @ W1à l’aller devientX.T @ d1au retour. Ce n’est pas une coïncidence : c’est la règle de dérivation en chaîne appliquée à un produit matriciel. - Aucune couche ne sait ce qu’elle détecte. La couche cachée a construit une représentation utile des quatre points, mais personne ne lui a dit laquelle. C’est ce que l’on appelle l’apprentissage de représentations, et c’est l’apport principal de l’apprentissage profond.
- Rien ici ne dépend du nombre de couches. Avec dix couches, on répète le même bloc dix fois. C’est cette uniformité qui a rendu possibles les réseaux profonds, et les bibliothèques comme PyTorch ou TensorFlow ne font rien d’autre que dériver automatiquement ces passes arrière.
Ce qui a rendu l’apprentissage profond possible
La rétropropagation est popularisée en 1986 par Rumelhart, Hinton et Williams. L’essor de l’apprentissage profond date des années 2010. Ce décalage de vingt-cinq ans n’est pas dû à une découverte théorique manquante, mais à trois conditions matérielles qui n’étaient pas réunies.
| Condition | Situation dans les années 1990 | Situation dans les années 2010 |
|---|---|---|
| Données annotées | quelques milliers d’exemples | ImageNet : environ 1,2 million d’images d’entraînement, 1000 catégories |
| Calcul | processeurs généralistes | cartes graphiques, massivement parallèles |
| Recettes d’entraînement | saturation des sigmoïdes, initialisations hasardeuses | ReLU, initialisations adaptées, normalisations, régularisation |
Le multiplicateur est brutal. Chaque itération de notre boucle traite 4 exemples et met à jour 17 paramètres. L’entraînement d’un réseau de vision courant traite des millions d’images et met à jour des dizaines de millions de paramètres, des dizaines de fois. La théorie est la même ; c’est l’échelle qui change de nature, et avec elle le coût, dont nous mesurerons les conséquences dans la dernière leçon.
À retenir
- Un neurone est une somme pondérée suivie d’une fonction. Le biais fait office de seuil, et l’arithmétique se vérifie à la main sur le ET logique.
- Une couche unique ne trace qu’une frontière droite : elle échoue sur le OU exclusif avec 0,5 de score. Empiler des couches sert à transformer l’espace jusqu’à ce que le problème devienne linéairement séparable.
- Les fonctions d’activation existent pour introduire de la non-linéarité. Sans elles, un réseau de dix couches est équivalent à une seule couche, quelle que soit sa taille.
- La descente de gradient mesure l’erreur, calcule la direction de correction et fait un petit pas. Le pas et l’échelle des entrées sont critiques : les mêmes données non mises à l’échelle font diverger la même boucle.
- La rétropropagation n’est que la dérivation en chaîne organisée de la sortie vers l’entrée. Elle tient en quelques lignes, elle est identique quel que soit le nombre de couches, et c’est ce qui la rend industrialisable.
Commentaires
Les commentaires sont alimentés par GitHub Discussions
Connectez-vous avec GitHub pour participer à la discussion