Entraîner un réseau de neurones artificiels (partie 2)

CSI 4506 - automne 2026

Marcel Turcotte

Version: nov. 1, 2026 17h45

Préambule

Message du jour

Objectifs d’apprentissage

  • Algorithme de rétropropagation :
    • Expliquer les signaux d’erreur et la règle de la chaîne le long d’un chemin et aux embranchements.
    • Relier les dérivées scalaires aux implémentations Python et NumPy.
    • Distinguer le calcul des gradients de la mise à jour des paramètres.
  • Implémentation :
    • Expliquer un perceptron multicouche à unités sigmoïdes qui conserve les valeurs du passage avant et utilise la rétropropagation par mini-lots.

Notation

Unités, couches et fonctions d’activation

Symbole Signification
\(z^{(l)}\) Préactivations : sommes pondérées et biais de la couche \(l\)
\(a^{(l)}\) Activations : sorties de la couche, notées \(o\) pour les unités dans la présentation 11
\(\varphi\) Fonction d’activation générale, appliquée élément par élément
\(\sigma\) Fonction d’activation sigmoïde logistique
\(W^{(l)}, b^{(l)}\) Poids et biais de la couche
\(f_l\) Transformation complète d’une couche : calcul affine suivi de l’activation

Probabilités, étiquettes et perte

Pour la classification binaire, comme dans les présentations 6 et 12 :

  • \(\hat p\) est la probabilité prédite de la classe 1.
  • \(\hat y=\mathbf{1}[\hat p\geq 0.5]\) est l’étiquette de classe prédite.
  • \(\ell\) est la perte d’un exemple et \(J\) est la perte moyenne.

\[ \ell(y,\hat p)=-\left[y\log\hat p+(1-y)\log(1-\hat p)\right] \]

\[ \qquad J(\theta)=\frac{1}{N}\sum_{i=1}^{N}\ell(y_i,\hat p_i). \]

Notation : réseau à deux couches

Une couche cachée et une couche de sortie :

\[ a^{(2)}=f_2(f_1(x)) \]

Chaque couche calcule

\[ f_l(a)=\varphi(aW^{(l)}+b^{(l)}). \]

Notation : réseau à trois couches

Deux couches cachées et une couche de sortie :

\[ a^{(3)}=f_3(f_2(f_1(x))) \]

où

\[ f_l(a)=\varphi(aW^{(l)}+b^{(l)}). \]

Notation : réseau à \(L\) couches

\[ a^{(L)}=f_L(\ldots f_2(f_1(x))\ldots). \]

Pour \(l=1,\ldots,L\) :

\[ z^{(l)}=a^{(l-1)}W^{(l)}+b^{(l)}, \qquad a^{(l)}=\varphi(z^{(l)}). \]

La sortie d’une couche devient l’entrée de la suivante.

Rétropropagation

Rétropropagation

Learning representations by back-propagating errors

David E. Rumelhart, Geoffrey E. Hinton & Ronald J. Williams

Nous décrivons une nouvelle procédure d’apprentissage, la rétropropagation, pour des réseaux d’unités semblables à des neurones. La procédure ajuste les poids de façon répétée dans les connexions du réseau afin de minimiser une mesure de la différence entre le vecteur de sortie réel du réseau et le vecteur de sortie souhaité. Ces ajustements amènent les unités internes « cachées », qui ne font partie ni de l’entrée ni de la sortie, à représenter des caractéristiques importantes du domaine de la tâche. Les interactions entre ces unités capturent les régularités de la tâche. La capacité de créer de nouvelles caractéristiques utiles distingue la rétropropagation des méthodes antérieures plus simples, comme la procédure de convergence du perceptron.

Représentation et apprentissage

  • Un perceptron à une seule couche ne peut séparer que des classes linéairement séparables.

  • Un réseau multicouche peut représenter XOR, comme l’a montré la présentation 11.

  • Apprendre les paramètres de ses couches cachées exige de résoudre le problème d’attribution du crédit (credit assignment problem) : comment chaque paramètre influence-t-il la perte finale?

La rétropropagation dans l’article de 1986

  • L’article utilisait un objectif d’erreur quadratique et des unités sigmoïdes.

  • Une propagation avant calcule les activations, puis une propagation arrière calcule les gradients des paramètres.

  • La descente de gradient utilise ces gradients pour mettre les paramètres à jour.

  • Les unités cachées peuvent apprendre des représentations internes utiles.

Idée conceptuelle

Idée conceptuelle (suite)

Idée conceptuelle (suite)

Rétropropagation

  • La rétropropagation est un algorithme qui calcule méthodiquement les dérivées partielles de la fonction de perte d’un réseau de neurones par rapport à chaque paramètre de poids et de biais.

  • La rétropropagation applique récursivement la règle de la chaîne pour calculer \(\frac{\partial J}{\partial w_{i,j}^{(l)}}\) et \(\frac{\partial J}{\partial b_j^{(l)}}\) pour chaque couche \(l\), en réutilisant efficacement les quantités intermédiaires du passage avant.

La sigmoïde et sa dérivée

\[ \sigma(z)=\frac{1}{1+e^{-z}},\qquad \sigma'(z)=\sigma(z)(1-\sigma(z)). \]

Afficher le code
import numpy as np
import matplotlib.pyplot as plt
from scipy.special import expit

z_values = np.linspace(-6, 6, 300)
a_values = expit(z_values)
fig, axes = plt.subplots(1, 2, figsize=(10, 3))
axes[0].plot(z_values, a_values)
axes[0].set(title="Sigmoïde", xlabel="z", ylabel="activation")
axes[1].plot(z_values, a_values * (1 - a_values))
axes[1].set(title="Dérivée de la sigmoïde", xlabel="z", ylabel="pente locale")
for ax in axes:
    ax.grid(True)
plt.tight_layout()
plt.show()

Règle de la chaîne

Soit

\[ h(x) = f(g(x)) \]

en notation de Lagrange, nous avons

\[ h^\prime(x) = f^\prime(g(x)) g^\prime(x) \]

ou, de façon équivalente, en notation de Leibniz

\[ \frac{dv}{dx} = \frac{dv}{du} \cdot \frac{du}{dx}, \qquad u=g(x),\quad v=f(u). \]

Application récursive de la règle de la chaîne

Graphe de calcul

Entrée scalaire et une unité cachée

Soit

\[ \ell = -\Bigl[y \,\log(\hat p) + (1-y)\,\log(1-\hat p)\Bigr] \]

\[ \hat p = a_2 = \sigma(z_2), \quad z_2 = w_2 \cdot a_1 + b_2 \]

\[ a_1 = \sigma(z_1), \quad z_1 = w_1 \cdot x + b_1 \]

Dérivées

\[ \frac{\partial \ell}{\partial \hat p} \]

\[ \frac{\partial \hat p}{\partial z_2} \]

\[ \frac{\partial z_2}{\partial w_2}, \quad \frac{\partial z_2}{\partial b_2}, \quad \frac{\partial z_2}{\partial a_1}, \]

\[ \frac{\partial a_1}{\partial z_1}, \]

\[ \frac{\partial z_1}{\partial w_1}, \quad \frac{\partial z_1}{\partial b_1}, \quad \frac{\partial z_1}{\partial x}, \]

Dérivées

Dérivée de la perte par rapport à \(\hat p\) :

\[ \frac{\partial \ell}{\partial \hat p} = -\left(\frac{y}{\hat p}-\frac{1-y}{1-\hat p}\right) \]

Dérivées

Dérivée de \(\hat p\) par rapport à \(z_2\) :

\[ \frac{\partial \hat p}{\partial z_2}=\sigma^{\prime}\left(z_2\right)=\hat p(1-\hat p) \]

Dérivées

Dérivées de \(z_2 = w_2 a_1 + b_2\) :

\[ \frac{\partial z_2}{\partial w_2}=a_1, \quad \frac{\partial z_2}{\partial b_2}=1, \quad \frac{\partial z_2}{\partial a_1}=w_2 \]

Dérivées

Dérivée de \(a_1 = \sigma(z_1)\) :

\[ \frac{\partial a_1}{\partial z_1}=\sigma^{\prime}\left(z_1\right)=a_1\left(1-a_1\right) \]

Dérivées

Dérivées de \(z_1 = w_1 x + b_1\) :

\[ \frac{\partial z_1}{\partial w_1}=x, \quad \frac{\partial z_1}{\partial b_1}=1, \quad \frac{\partial z_1}{\partial x} = w_1 \]

Dérivées combinées

Pour \(w_2\) :

\[ \frac{\partial \ell}{\partial w_2}=\frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial w_2}=\left[-\left(\frac{y}{\hat p}-\frac{1-y}{1-\hat p}\right)\right] \cdot(\hat p(1-\hat p)) \cdot a_1 \]

Après simplification :

\[ \frac{\partial \ell}{\partial w_2}=(\hat p-y) a_1 \]

Dérivées combinées

Pour \(b_2\) :

\[ \frac{\partial \ell}{\partial b_2}=\frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial b_2}=(\hat p-y) \cdot 1=\hat p-y \]

Dérivées combinées

Pour \(w_1\) :

\[ \frac{\partial \ell}{\partial w_1}=\frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial w_1} \]

En remplaçant les dérivées locales :

\[ =\left[-\left(\frac{y}{\hat p}-\frac{1-y}{1-\hat p}\right)\right] \cdot(\hat p(1-\hat p)) \cdot w_2 \cdot\left(a_1\left(1-a_1\right)\right) \cdot x \]

Après simplification :

\[ \frac{\partial \ell}{\partial w_1}=(\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) x \]

Dérivées combinées

Pour \(b_1\) :

\[ \frac{\partial \ell}{\partial b_1}=\frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial b_1} \]

En remplaçant les dérivées locales :

\[ =(\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) \cdot 1 \]

Après simplification :

\[ \frac{\partial \ell}{\partial b_1}=(\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) \]

Dérivées principales

\[ \begin{align} \frac{\partial \ell}{\partial w_2} & = (\hat p-y) a_1 \\ \frac{\partial \ell}{\partial b_2} & = \hat p-y \\ \frac{\partial \ell}{\partial w_1} & = (\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) x \\ \frac{\partial \ell}{\partial b_1} & = (\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) \\ \end{align} \]

Exploration

import math
import random

random.seed(42)

def sigma(x):
    return 1 / (1 + math.exp(-x))

alpha = 0.1

def init():

    global w1, w2, b1, b2

    w1 = random.random()
    w2 = random.random()
    b1 = 0
    b2 = 0

Propagation avant

def forward():

    global z1, a1, z2, a2, loss, p_hat

    z1 = w1 * x + b1
    a1 = sigma(z1)

    z2 = w2 * a1 + b2
    a2 = sigma(z2)

    p_hat = a2

    loss = -(y * math.log(p_hat) + (1-y) * math.log(1 - p_hat))

Propagation arrière

def backward():

    grad_loss_w2 = (p_hat - y) * a1
    grad_loss_b2 = p_hat - y

    grad_loss_w1 = (p_hat - y) * w2 * (a1 * (1-a1)) * x
    grad_loss_b1 = (p_hat - y) * w2 * (a1 * (1-a1))

    return grad_loss_w1, grad_loss_b1, grad_loss_w2, grad_loss_b2

Mise à jour par gradient

def step(gradients):

    global w1, b1, w2, b2

    grad_loss_w1, grad_loss_b1, grad_loss_w2, grad_loss_b2 = gradients

    w1 = w1 - alpha * grad_loss_w1
    b1 = b1 - alpha * grad_loss_b1
    w2 = w2 - alpha * grad_loss_w2
    b2 = b2 - alpha * grad_loss_b2

Entraînement

init()

x = 3.14
y = 1

forward()
print(f"Avant : p_hat = {p_hat:.2}, perte = {loss:.2}")

for i in range(500):
    forward()
    gradients = backward()
    step(gradients)

forward()
print(f"Après : p_hat = {p_hat:.2}, perte = {loss:.2}")
Avant : p_hat = 0.51, perte = 0.68
Après : p_hat = 0.99, perte = 0.01

Dérivées principales

\[ \begin{align} \frac{\partial \ell}{\partial w_2} & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial w_2}\\ \frac{\partial \ell}{\partial b_2} & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial b_2}\\ \frac{\partial \ell}{\partial w_1} & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial w_1}\\ \frac{\partial \ell}{\partial b_1} & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial b_1}\\ \end{align} \]

Signaux d’erreur

Soit

\[ \begin{align} \delta_2 & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \\ \delta_1 & = \delta_2 \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \end{align} \]

Réécrivons

\[ \begin{align} \frac{\partial \ell}{\partial w_2} & = \delta_2 \cdot \frac{\partial z_2}{\partial w_2}\\ \frac{\partial \ell}{\partial b_2} & = \delta_2 \cdot \frac{\partial z_2}{\partial b_2}\\ \frac{\partial \ell}{\partial w_1} & = \delta_1 \cdot \frac{\partial z_1}{\partial w_1}\\ \frac{\partial \ell}{\partial b_1} & = \delta_1 \cdot \frac{\partial z_1}{\partial b_1}\\ \end{align} \]

Propagation arrière avec signaux d’erreur

def backward_with_signals():

    delta2 = p_hat - y
    delta1 = delta2 * w2 * a1 * (1 - a1)

    grad_loss_w2 = delta2 * a1
    grad_loss_b2 = delta2
    grad_loss_w1 = delta1 * x
    grad_loss_b1 = delta1

    return grad_loss_w1, grad_loss_b1, grad_loss_w2, grad_loss_b2

Une activation alimente deux unités

\[ z_u=ra+b_u,\qquad u=\sigma(z_u) \]

\[ z_v=sa+b_v,\qquad v=\sigma(z_v) \]

\[ \hat p=\sigma(tu+qv+b_{\mathrm{out}}) \]

Avec \(\delta_u=\partial\ell/\partial z_u\) et \(\delta_v=\partial\ell/\partial z_v\) :

\[ \frac{\partial\ell}{\partial a} =\underbrace{r\delta_u}_{\text{par }u} +\underbrace{s\delta_v}_{\text{par }v}. \]

Embranchement : Python

a = sigma(0.0)
r, s = 2.0, -1.0
t, q = 1.0, 1.0
u = sigma(r * a - 1.0)
v = sigma(s * a + 0.5)
p_hat = sigma(t * u + q * v - 1.0)
y = 1

delta_out = p_hat - y
delta_u = delta_out * t * u * (1 - u)
delta_v = delta_out * q * v * (1 - v)

through_u = r * delta_u
through_v = s * delta_v
delta_a = (through_u + through_v) * a * (1 - a)

print(f"Contributions : {through_u:.3f} + {through_v:.3f}")
print(f"d_loss/da: {through_u + through_v:.3f}")
print(f"delta_a: {delta_a:.5f}")
Contributions : -0.250 + 0.125
d_loss/da: -0.125
delta_a: -0.03125

Entraînement avec rétropropagation

  1. Initialiser les poids et les biais.
  2. Propagation avant : calculer les activations et conserver les valeurs intermédiaires.
  3. Calculer la perte du lot courant.
  4. Propagation arrière : calculer les gradients de tous les paramètres.
  5. Mise à jour par gradient : modifier les paramètres à l’aide de ces gradients.
  6. Répéter pour les lots et les époques restants.

Rétropropagation sur un graphe de calcul

  • Commencer à la perte : sa dérivée par rapport à elle-même est \(\frac{\partial\ell}{\partial\ell}=1\).
  • Visiter les opérations dans l’ordre inverse du calcul.
  • Pour chaque entrée \(u\), additionner les contributions de toutes ses utilisations en aval:

\[ \frac{\partial\ell}{\partial u} =\sum_{z\text{ utilise directement }u} \frac{\partial z}{\partial u}\frac{\partial\ell}{\partial z}. \]

Initialisation

  • Initialiser les poids aléatoirement pour briser la symétrie entre les unités.
  • Les biais nuls sont un choix courant.
  • Adapter l’échelle des poids à la couche et à la fonction d’activation.

Propagation avant et perte

  • Appliquer la transformation affine et l’activation de chaque couche.
  • Conserver les préactivations et activations nécessaires au passage arrière.
  • Pour la classification binaire, la sortie sigmoïde est la probabilité \(\hat p\).
  • Calculer l’entropie croisée binaire moyenne sur le lot courant.

Propagation arrière et mise à jour des paramètres

  1. Calculer le signal d’erreur de la couche de sortie.
  2. Remonter les couches cachées : somme pondérée des signaux en aval, puis dérivée de l’activation.
  3. Utiliser les activations et signaux d’erreur de chaque couche pour calculer les gradients de ses paramètres.
  4. Une fois tous les gradients disponibles, mettre les paramètres à jour :

\[ \theta\leftarrow\theta-\alpha\nabla_\theta J_B. \]

Implémentation

Rétropropagation : passage avant

  • Calculer les activations couche par couche :

    \(z^{(l)} = a^{(l-1)} W^{(l)} + b^{(l)}\),

    \(a^{(l)} = \varphi(z^{(l)})\).

  • Pour la classification binaire, la sortie est une probabilité \(\hat p\). Calculer la perte d’un exemple \(\ell(y,\hat p)\).

Rétropropagation : passage arrière

  • Couche de sortie, pour un exemple avec sortie sigmoïde et entropie croisée binaire :

\[ \delta^{(L)}=\hat p-y. \]

  • Couches cachées, dans l’ordre inverse \(l=L-1,\ldots,1\) :

\[ \delta^{(l)} = (\delta^{(l+1)}(W^{(l+1)})^\top) \odot \varphi'(z^{(l)}) \]

D’un exemple à un lot

Empiler les activations et signaux d’erreur de chaque exemple comme lignes de \(A^{(l)}\) et \(\Delta^{(l)}\).

Pour un lot de \(B\) exemples :

\[ \frac{\partial J_B}{\partial W^{(l)}} =\frac{1}{B}(A^{(l-1)})^\top\Delta^{(l)}, \qquad \frac{\partial J_B}{\partial b^{(l)}} =\frac{1}{B}\sum_{i=1}^{B}\delta_i^{(l)}. \]

grad_W = activations_prev.T @ delta / batch_size
grad_b = delta.mean(axis=0)

SimplerMLP

Unités sigmoïdes, entropie croisée binaire et taux d’apprentissage constant.

Afficher le code
import numpy as np

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))

def sigmoid_prime(z):
    a = sigmoid(z)
    return a * (1.0 - a)

def bce_loss(y_true, y_prob, eps=1e-9):
    """Entropie croisée binaire moyennée sur les exemples."""
    p = np.clip(y_prob, eps, 1 - eps)
    return -np.mean(y_true * np.log(p) + (1 - y_true) * np.log(1 - p))

def xavier_init(rng, fan_in, fan_out):
    std = np.sqrt(2.0 / (fan_in + fan_out))
    return rng.normal(0.0, std, size=(fan_in, fan_out))

class SimplerMLP:
    """MLP sigmoïde pour la classification binaire; taux constant."""

    def __init__(self, layer_sizes, lr=0.1, seed=None):
        self.sizes = list(layer_sizes)
        self.lr = float(lr)
        self.rng = np.random.default_rng(seed)

        self.W = []
        for din, dout in zip(self.sizes[:-1], self.sizes[1:]):
            Wk = xavier_init(self.rng, din, dout)
            self.W.append(Wk)
        self.b = [np.zeros(dout) for dout in self.sizes[1:]]

    def forward(self, X):
        a = X
        for W, b in zip(self.W, self.b):
            a = sigmoid(a @ W + b)
        return a.ravel()

    def predict_proba(self, X):
        return self.forward(X)

    def predict(self, X, threshold=0.5):
        return (self.predict_proba(X) >= threshold).astype(int)

    def loss(self, X, y):
        p = self.predict_proba(X)
        return bce_loss(y, p)

    def _forward_full(self, X):
        a = X
        activations = [a]
        zs = []
        for W, b in zip(self.W, self.b):
            z = a @ W + b
            a = sigmoid(z)
            zs.append(z)
            activations.append(a)
        return activations, zs

    def train(self, X, y, epochs=30, batch_size=None, verbose=True):
        """X : (N, d), y : (N,); batch_size=None utilise tout le jeu de données."""
        N = X.shape[0]
        idx = np.arange(N)
        B = N if batch_size is None else int(batch_size)

        for ep in range(1, epochs + 1):
            self.rng.shuffle(idx)

            base_loss = self.loss(X, y)

            for start in range(0, N, B):
                sl = idx[start:start + B]
                Xb = X[sl]
                yb = y[sl].reshape(-1, 1)
                Bsz = Xb.shape[0]

                activations, zs = self._forward_full(Xb)
                A_L = activations[-1]

                # Chaque ligne est la sensibilité aux préactivations d'un exemple.
                delta = A_L - yb
                grads_W = [None] * len(self.W)
                grads_b = [None] * len(self.b)

                # Calculer une seule fois la moyenne des gradients sur les exemples.
                grads_W[-1] = activations[-2].T @ delta / Bsz
                grads_b[-1] = delta.mean(axis=0)

                for k in range(len(self.W) - 2, -1, -1):
                    z = zs[k]
                    d_act = sigmoid_prime(z)
                    delta = (delta @ self.W[k + 1].T) * d_act
                    grads_W[k] = activations[k].T @ delta / Bsz
                    grads_b[k] = delta.mean(axis=0)

                # Tous les gradients utilisent les poids du même passage avant.
                for k in range(len(self.W)):
                    self.W[k] -= self.lr * grads_W[k]
                    self.b[k] -= self.lr * grads_b[k]

            new_loss = self.loss(X, y)
            if verbose:
                print(f"Époque {ep:3d} | perte {base_loss:.5f} -> {new_loss:.5f} | lr={self.lr:.4f}")

Activation et dérivée

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))

def sigmoid_prime(z):
    a = sigmoid(z)
    return a * (1.0 - a)

Perte

def bce_loss(y_true, y_prob, eps=1e-9):
    """Entropie croisée binaire moyennée sur les exemples."""
    p = np.clip(y_prob, eps, 1 - eps)
    return -np.mean(y_true * np.log(p) + (1 - y_true) * np.log(1 - p))

Initialisation des paramètres

def xavier_init(rng, fan_in, fan_out):
    std = np.sqrt(2.0 / (fan_in + fan_out))
    return rng.normal(0.0, std, size=(fan_in, fan_out))

Définition de la classe et constructeur

class SimplerMLP:

    def __init__(self, layer_sizes, lr=0.1, seed=None):
        self.sizes = list(layer_sizes)
        self.lr = float(lr)
        self.rng = np.random.default_rng(seed)

        self.W = []
        for din, dout in zip(self.sizes[:-1], self.sizes[1:]):
            Wk = xavier_init(self.rng, din, dout)
            self.W.append(Wk)
        self.b = [np.zeros(dout) for dout in self.sizes[1:]]

Forward (public)

    def forward(self, X):
        a = X
        for W, b in zip(self.W, self.b):
            a = sigmoid(a @ W + b)
        return a.ravel()

Forward (privé)

    def _forward_full(self, X):
        a = X
        activations = [a]
        zs = []
        for W, b in zip(self.W, self.b):
            z = a @ W + b
            a = sigmoid(z)
            zs.append(z)
            activations.append(a)
        return activations, zs

Entraînement

    def train(self, X, y, epochs=30, batch_size=None, verbose=True):
        """X : (N, d), y : (N,); batch_size=None utilise tout le jeu de données."""
        N = X.shape[0]
        idx = np.arange(N)
        B = N if batch_size is None else int(batch_size)

        for ep in range(1, epochs + 1):
            self.rng.shuffle(idx)

            base_loss = self.loss(X, y)

            for start in range(0, N, B):
                sl = idx[start:start + B]
                Xb = X[sl]
                yb = y[sl].reshape(-1, 1)
                Bsz = Xb.shape[0]

                activations, zs = self._forward_full(Xb)
                A_L = activations[-1]

                # Chaque ligne est la sensibilité aux préactivations d'un exemple.
                delta = A_L - yb
                grads_W = [None] * len(self.W)
                grads_b = [None] * len(self.b)

                # Calculer une seule fois la moyenne des gradients sur les exemples.
                grads_W[-1] = activations[-2].T @ delta / Bsz
                grads_b[-1] = delta.mean(axis=0)

                for k in range(len(self.W) - 2, -1, -1):
                    z = zs[k]
                    d_act = sigmoid_prime(z)
                    delta = (delta @ self.W[k + 1].T) * d_act
                    grads_W[k] = activations[k].T @ delta / Bsz
                    grads_b[k] = delta.mean(axis=0)

                # Tous les gradients utilisent les poids du même passage avant.
                for k in range(len(self.W)):
                    self.W[k] -= self.lr * grads_W[k]
                    self.b[k] -= self.lr * grads_b[k]

            new_loss = self.loss(X, y)
            if verbose:
                print(f"Époque {ep:3d} | perte {base_loss:.5f} -> {new_loss:.5f} | lr={self.lr:.4f}")

Évaluation

from sklearn.datasets import make_circles
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score

X, y = make_circles(n_samples=200, factor=0.5, noise=0.08, random_state=1)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y)

scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

model = SimplerMLP([2, 4, 4, 1], lr=0.3, seed=42)
model.train(X_train, y_train, epochs=2000, batch_size=32, verbose=False)

print("Exactitude (entraînement) :", accuracy_score(y_train, model.predict(X_train)))
print("Exactitude (test) :", accuracy_score(y_test, model.predict(X_test)))
Exactitude (entraînement) : 1.0
Exactitude (test) : 1.0

Différentiation automatique

La différentiation automatique (autodiff) applique systématiquement la règle de la chaîne pour calculer les dérivées exactes de fonctions exprimées sous forme de programmes. Elle propage les dérivées à travers les opérations élémentaires, soit vers l’avant, des entrées aux sorties, soit vers l’arrière, des sorties aux entrées. Elle permet ainsi de calculer efficacement les gradients utilisés en optimisation et en apprentissage.

Épilogue

Résumé

Résumé

Concept Rôle
Fonctions d’activation Introduisent la non-linéarité (sigmoïde dans SimplerMLP).
Fonction de perte Mesure l’erreur de prédiction, par exemple avec l’entropie croisée binaire.
Taux d’apprentissage (α) Contrôle la taille des mises à jour des paramètres.
Descente de gradient Méthode d’optimisation pour ajuster les poids.
Règle de la chaîne Mécanisme de propagation des dérivées vers l’arrière.
Différentiation automatique Automatise le calcul des dérivées. La rétropropagation utilise le mode inverse.

De la rétropropagation à l’implémentation

  • Conserver les activations et préactivations du passage avant.
  • Calculer les signaux d’erreur vers l’arrière et additionner les contributions aux embranchements.
  • Calculer la moyenne des gradients des paramètres sur les exemples, puis mettre tous les paramètres à jour.
  • SimplerMLP implémente ce processus avec des unités sigmoïdes et un taux d’apprentissage constant.

3Blue1Brown

Une série de vidéos animées qui développe l’intuition derrière l’algorithme de rétropropagation.

StatQuest

Herman Kamper

Une série de vidéos particulièrement approfondie sur l’algorithme de rétropropagation.

Livre gratuit avec implémentation

Dans son livre Neural Networks and Deep Learning, Michael Nielsen fournit une implémentation Python complète d’un réseau de neurones.

Prochaine présentation

  • Propagation des gradients, fonctions d’activation et initialisation.
  • Extension de SimplerMLP en SimpleMLP avec choix d’activations, régularisation L2 et décroissance du taux d’apprentissage.
  • Softmax, entropie croisée et régularisation.

Références

Angermueller, Christof, Tanel Pärnamaa, Leopold Parts, et Oliver Stegle. 2016. « Deep learning for computational biology ». Mol Syst Biol 12 (7): 878. https://doi.org/10.15252/msb.20156651.
Baydin, Atılım Günes, Barak A. Pearlmutter, Alexey Andreyevich Radul, et Jeffrey Mark Siskind. 2017. « Automatic differentiation in machine learning: a survey ». J. Mach. Learn. Res. 18 (1): 5595‑637.
Bicknell, Klinton, Claire Brust, et Burr Settles. 2023. « How Duolingo’s AI Learns what you Need to Learn: The language-learning app tries to emulate a great human tutor ». IEEE Spectrum 60 (3): 28‑33. https://doi.org/10.1109/MSPEC.2023.10061631.
Rumelhart, David E., Geoffrey E. Hinton, et Ronald J. Williams. 1986. « Learning representations by back-propagating errors ». Nature 323 (6088): 533‑36. https://doi.org/10.1038/323533a0.
Russell, Stuart, et Peter Norvig. 2020. Artificial Intelligence: A Modern Approach. 4ᵉ éd. Pearson. http://aima.cs.berkeley.edu/.

Annexe : Dérivées principales (SymPy)

Dérivées principales avec SymPy

Code
import sympy as sp
from IPython.display import Math, display

def sigmoid(z):
    return 1 / (1 + sp.exp(-z))

x, y = sp.symbols("x y", real=True)
w1, b1, w2, b2 = sp.symbols("w_1 b_1 w_2 b_2", real=True)

# Expressions imbriquées pour que la perte conserve sa dépendance à chaque paramètre.

z1 = w1 * x + b1
a1 = sigmoid(z1)

z2 = w2 * a1 + b2
p_hat = sigmoid(z2)

loss = -(y * sp.log(p_hat) + (1 - y) * sp.log(1 - p_hat))

gradients = {parameter: sp.diff(loss, parameter) for parameter in (w2, b2, w1, b1)}

# Réécriture pour améliorer la lisibilité

a1_symbol, p_symbol = sp.symbols("a_1 p_hat", real=True)

rewrites = [
  (sp.exp(-z2), (1 - p_symbol) / p_symbol),
  (sp.exp(-z1), (1 - a1_symbol) / a1_symbol),
]

for parameter, gradient in gradients.items():
    named_gradient = sp.factor(gradient.subs(rewrites))
    lhs = rf"\frac{{\partial \ell}}{{\partial {sp.latex(parameter)}}}"
    rhs = sp.latex(named_gradient, symbol_names={p_symbol: r"\hat p"})
    display(Math(f"{lhs} = {rhs}"))

\(\displaystyle \frac{\partial \ell}{\partial w_{2}} = - a_{1} \left(- \hat p + y\right)\)

\(\displaystyle \frac{\partial \ell}{\partial b_{2}} = \hat p - y\)

\(\displaystyle \frac{\partial \ell}{\partial w_{1}} = a_{1} w_{2} x \left(a_{1} - 1\right) \left(- \hat p + y\right)\)

\(\displaystyle \frac{\partial \ell}{\partial b_{1}} = a_{1} w_{2} \left(a_{1} - 1\right) \left(- \hat p + y\right)\)

Marcel Turcotte

[email protected]

École de génie électrique et d’informatique

Université d’Ottawa