Travaux pratiques - Perceptron multi-couche

L’objectif de cette séance de travaux pratiques est d’étendre le modèle de régression logistique du TP précédent à un réseau de neurones plus complexe. Précédemment, nous avons implémenté à la main une régression logistique, c’est-à-dire le plus simple des réseaux de neurones linéaires. Pour cette séance, nous allons implémenter un perceptron multi-couches (multi-layer perceptron ou MLP). Contrairement à la régression logistique, qui se limite à des séparateurs linéaires, le perceptron permet d’apprendre des frontières de décisions non-linéaires. En outre, la famille des perceptrons est un approximateur universels des fonctions continues. Autrement dit, il s’agit d’un modèle très expressif, à la base de l’apprentissage profond.

Nous allons voir dans un premier temps comment implémenter la phase forward (passe avant) pour produire des prédictions, puis la phase forward (passe arrière) pour l’optimisation d’un perceptron à une seule couche cachée.

Commençons par charger à nouveau le jeu de données MNIST :

from keras.datasets import mnist
# Import de MNIST depuis Keras
(X_train, y_train), (X_test, y_test) = mnist.load_data()
# Transformation des images 28x28 en vecteur de dimension 784
X_train = X_train.reshape(60000, 784).astype('float32')
X_test = X_test.reshape(10000, 784).astype('float32')
# Normalisation entre 0 et 1
X_train /= 255
X_test /= 255

Prédiction avec un perceptron (forward)

L’architecture du perceptron à une seule couche cachée est illustrée dans la figure ci-dessous.

Comme la dernière fois, on considère les données de la base MNIST. Chaque image est représentée par un vecteur de taille \(28^2=784\). Le perceptron applique différentes opérations mathématiques pour transformer l’entrée et produire la prédiction finale, c’est-à-dire la catégorie de l’image :

  1. Une projection linéaire, qui va projeter chaque image sur un vecteur de dimensions \((1, L)\). \(L\) représente ici la largeur (le nombre de neurones) de la couche cachée du perceptron, par exemple \(L=100\). En considérant que chaque exemple \(\mathbf{x_i}\) est un vecteur ligne \((1,784)\), la projection linéaire est représentée par une matrice \(\mathbf{W^h}\) \((784, L)\) et un vecteur de biais \(\mathbf{b^h}\) \((1, L)\). La projection s’écrit:

    \[\mathbf{u_i} = \mathbf{x_i} \mathbf{W^h} + \mathbf{b^h}.\]
  2. L’application d’une fonction de transfert non-linéaire, par exemple une sigmoïde :

    \[\forall j \in \left\lbrace 1; L \right\rbrace, ~ h_{i,j} = \frac{1}{1+\exp(-u_{i,j})}.\]
  3. Une deuxième projection linéaire, qui va projeter la représentation interne (les activations de la couche cachée) de dimensions \((1,L)\) en un vecteur de \((1, K)\), avec \(K\) le nombre de classes considérées (ici, 10). \(K\) représente le nombre de neurones en sortie, c’est-à-dire la dimensionalité du vecteur prédit. Cette opération de projection linéaire est représentée par la matrice \(\mathbf{W^y}\) de dimensions \((L, K)\) et le vecteur de biais \(\mathbf{b^y}\) de dimensions \((1, K)\)). Matriciellement, la projection est représentée par l’opération :

    \[\mathbf{v_i} =\mathbf{h_i} \mathbf{W^y} + \mathbf{b^y}.\]
  4. Enfin, l’application d’une non-linéarité softmax. Comme pour la régression logistique, cela permet de transformer les activations de sortie en probabilités pour une distribution catégorielle :

    \[\forall j \in \left\lbrace 1; K \right\rbrace ~ y_{i,j} = \frac{\exp(v_{i,j})}{\sum\limits_{k=1}^K \exp(v_{i,k})}.\]

Notre objectif pour cette séance va être d’implémenter un perceptron (et son apprentissage) sur la base MNIST. Commençons par transformer les étiquettes en vecteur encodé au format one-hot:

from keras.utils import np_utils
n_classes = 10
# Conversion des étiquettes au format one-hot
Y_train = np_utils.to_categorical(y_train, n_classes)
Y_test = np_utils.to_categorical(y_test, n_classes)

Question

En reprenant le squelette du code de la régression logistique, compléter le code ci-dessous pour implémenter la forward pass (phase de prédiction) du perceptron multi-couche.

Vous devrez notamment écrire une fonction forward(batch, Wh, bh, Wy, by) qui renvoie la prédiction \(\hat{\mathbf{y}}\) ainsi que la matrice des activations de la couche cachée. Si l’on considère un batch des données de taille \(n \times 784\), les paramètres \(\mathbf{W^h}\) (\(784\times L\)), \(\mathbf{b^h}\) (\(1\times L\)), \(\mathbf{W^y}\)(\(L\times K\)) et \(\mathbf{b^y}\) (\(1\times K\)), la fonction forward renvoie :

  • la prédiction \(\mathbf{\hat{Y}}\) sur le batch (\(n\times K\)),

  • la matrice \(\mathbf{H}\) des activations de la couche cachée (\(n\times L\)),

pour un batch de \(n\) exemples.

Correction

def forward(batch, Wh, bh, Wy, by):
    """ Entrées:
        - batch: un batch de n images de MNIST au format vecteur (n, 784)
        - Wh: une matrice de poids entrée -> couche cachée
        - bh: un vecteur de biais pour la couche cachée
        - Wy: une matrice de poids couche cachée -> sortie
        - by: un vecteur de biais pour la sortie

        Renvoie:
        - Y_pred: prédictions de sortie
        - H: activations de la couche cachée
    """

    # Activations de la couche cachée:
    # H = Wh * X + bh
    H = np.matmul(batch, Wh) + bh.repeat(batch.shape[0], axis=0)
    # Fonction de transfert non-linéaire:
    # H = sigmoide(H)
    H = 1.0 / (1.0 + np.exp(-H))
    # Couche de sortie:
    # Y = Wy * H + by
    Y_pred = np.matmul(H, Wy) + by.repeat(H.shape[0], axis=0)
    # Calcul du softmax: Y = e^Y / sum(e^Y)
    Y_pred = np.exp(Y_pred)
    Y_pred = Y_pred / np.sum(Y_pred, axis=1)[:,None]
    return Y_pred, H

Apprentissage du perceptron (backward)

Comme pour la régression logistique, nous allons entraîner le perceptron à l’aide de l’algorithme de descente de gradient. Pour calculer les gradients par rapport aux paramètres de la couche cachée, nous allons avoir besoin d’utiliser l’algorithme de rétro-propagation du gradient (backpropagation). Rappellons que pour chaque batch d’exemples, l’algorithme effectue une passe forward (implémentée ci-dessus) qui permet de calculer la prédiction du perceptron pour les exemples du batch.

La fonction de coût considérée sera encore l’entropie croisée entre la sortie prédite et les étiquettes de supervision. On calculera alors le gradient de l’erreur par rapport à tous les paramètres du modèle, c’est-à-dire:

  • \(\mathbf{W^y}\) (dimensions \((L, K)\)),

  • \(\mathbf{b^y}\) (dimensions \((1, K)\)),

  • \(\mathbf{W^h}\) (dimensions \((784, L)\)),

  • \(\mathbf{b^h}\) (dimensions \((1, L)\)).

On rappelle ci-dessous les équations des gradients, effectuées depuis la sortie vers l’entrée du réseau :

  1. Mise à jour de \(\mathbf{W^y}\) et \(\mathbf{b^y}\):

\[\frac{\partial \mathcal{L}}{\partial \mathbf{v_i}} = \mathbf{\delta^y_i} = \mathbf{\hat{y_i}} - \mathbf{y_i^*}\]
\[\frac{\partial \mathcal{L}}{\partial \mathbf{W^y}} = \frac{1}{n} \mathbf{H}^T (\mathbf{\hat{Y}} - \mathbf{Y^*}) = \frac{1}{n} \mathbf{H}^T \mathbf{\Delta^y} \tag{1}\]
\[\frac{\partial \mathcal{L}}{\partial \mathbf{b^y}} = \frac{1}{n}\sum_{i=1}^{n}(\mathbf{\hat{y_i}} - \mathbf{y_i^*}) \tag{2}\]

\(\mathbf{H}\) est la matrice des couches cachées sur le batch (\(784 \times L\)), \(\mathbf{\hat{Y}}\) est la matrice des prédictions sur le batch (taille \(n \times K\)), \(\mathbf{Y^*}\) est la matrice des étiquettes issues de la supervision (ground truth, \(n \times K\)) et \(\mathbf{\Delta^y}=\mathbf{\hat{Y}}-\mathbf{Y^*}\).

  1. Mise à jour de \(\mathbf{W^h}\)et \(\mathbf{b^h}\):

Question

Montrer que

\[\frac{\partial \mathcal{L}}{\partial \mathbf{u_i}} = \mathbf{\delta^h_i} = \mathbf{\delta^y_i} \mathbf{W^{y}}^T \odot \sigma^{'}(\mathbf{u_i}) = \mathbf{\delta^y_i} \mathbf{W^{y}}^T \odot (\mathbf{h_i} \odot (1-\mathbf{h_i}))\]

Question

En déduire que les gradients de \(\mathcal{L}\) par rapport à \(\mathbf{W^h}\) et \(\mathbf{b^h}\) s’écrivent matriciellement :

\[\frac{\partial \mathcal{L}}{\partial \mathbf{W^h}} = \frac{1}{n} \mathbf{X}^T \mathbf{\Delta^h} ~~~\text{et}~~~ \frac{\partial \mathcal{L}}{\partial \mathbf{b^h}} = \frac{1}{n}\sum_{i=1}^{n}(\delta^h_i)\]

\(\mathbf{X}\) est la matrice des données sur le batch (\(n \times 784\)) et \(\mathbf{\Delta^h}\) est la matrice des \(\delta^h_i\) sur le batch (\(n \times L\)).

Question

Compléter la fonction backward ci-dessous qui calcule et renvoie les gradients de l’erreur par rapport aux paramètres du perceptron.

Correction

def backward(Y_pred, Y, X, H, Wy):
""" Entrées:
    - Y_pred: batch de vecteur des prédictions (one-hot)
    - Y: batch de vecteur des étiquettes (one-hot)
    - X: batch d'images (au format vectoriel (n, 784))
    - H: matrice des activations cachées
    - Wy: matrice des poids

    Renvoie:
    - gradWy: gradient de l'erreur (entropie croisée) par rapport à Wy
    - gradby: gradient de l'erreur (entropie croisée) par rapport à by
    - gradWh: gradient de l'erreur (entropie croisée) par rapport à Wh
    - gradbh: gradient de l'erreur (entropie croisée) par rapport à bh

"""
    delta_y = Y_pred - Y
    # Gradient pour la couche de sortie (identique à la régression logistique)
    gradWy = 1./batch_size * np.matmul(np.transpose(H), delta_y)
    gradby = 1./batch_size * (delta_y.sum(axis=0)).reshape((1, n_classes))
    # Gradient pour la couche cachée
    delta_h = np.matmul(delta_y, np.transpose(Wy)) * (H * (1. - H))
    gradWh = 1./batch_size * np.matmul(np.transpose(X), delta_h)
    gradbh = 1./batch_size * (delta_h.sum(axis=0)).reshape((1, hidden_size))
    return gradWy, gradby, gradWh, gradbh

Question

La fonction de coût de l’Eq. (3) est-elle convexe par rapport aux paramètres \(\mathbf{W}\), \(\mathbf{b}\) du modèle ? Avec un pas de gradient bien choisi, peut-on assurer la convergence vers le minimum global de la solution ?

Question

Compléter le code ci-dessous de sorte à appliquer la descente de gradient sur le perceptron multi-couche défini par les paramètres Wy, Wh, by, bh.

Correction

import numpy as np
N, d = X_train.shape # N exemples, dimension d
hidden_size = 100 # Nombre de neurones de la couche cachée
# Initialisation des poids et des biais
Wy = np.zeros((hidden_size, n_classes))
Wh = np.zeros((d, hidden_size)
by = np.zeros((1, n_classes))
bh = np.zeros((1, hidden_size))

n_epochs = 20 # Nombre d'epochs de la descente de gradient
eta = 1e-1 # Learning rate (pas d'apprentissage)
batch_size = 100 # Taille du lot
n_batches = int(float(N) / batch_size)

# Allocation des matrices pour stocker les valeurs des gradients
gradWy = np.zeros((hidden_size, n_classes))
gradWh = np.zeros((d, hidden_size))
gradby = np.zeros((1, n_classes))
gradbh = np.zeros((1, hidden_size))

for epoch in range(n_epochs):
    for batch_idx in range(n_batches):
        X = X_train[batch_idx*batch_size:(batch_idx+1)*batch_size]
        Y = Y_train[batch_idx*batch_size:(batch_idx+1)*batch_size]
        # FORWARD PASS : calculer la prédiction y à partir des paramètres courants pour les images du batch
        Y_pred, H = forward(X, Wh, bh, Wy, by)
        # BACKWARD PASS :
        # 1) calculer les gradients de l'erreur par rapport à W et b
        gradWy, gradby, gradWh, gradbh = backward(Ypred,Y,X,H,Wy)
        # 2) mettre à jour les paramètres W et b selon la descente de gradient
        Wy = Wy - eta * gradWy
        by = by - eta * gradby
        Wh = Wh - eta * gradWh
        bh = bh - eta * gradbh

    print(f"Epoch {epoch}/{n_epochs}, accuracy (train) = {accuracy(X_train, Y_train, Wh, bh, Wy, by)*100:.2f}, accuracy (test) = {accuracy(X_test, Y_test, Wh, bh, Wy, by)*100:.2f})

Question

Tester en initialisant les matrices à 0 comme pour la régression logistique. Quelles performances obtenez-vous ? Que peut-on en conclure ?

Question

Tester deux autres initialisations :

  • initialiser les poids avec une loi normale de moyenne nulle et d’écart type à déterminer, par exemple \(10^{-1}\),

  • initialiser les poids selon la règle de Xavier Glorot [GB10], qui divise la valeur de la gaussienne par \(\sqrt{n_i}\), où \(n_i\) est le nombre de neurones en entrée de la couche.

Question

Évaluer les performances du modèle en classification. Vous devriez obtenir un score d’environ 98% (accuracy) sur la base de test pour ce réseau de neurones à une couche cachée.

[GB10] Xavier Glorot and Yoshua Bengio. Understanding the difficulty of training deep feedforward neural networks. In In Proceedings of the International Conference on Artificial Intelligence and Statistics (AISTATS’10). Society for Artificial Intelligence and Statistics. 2010.