L’objectif de ce projet est d’implémenter un réseau de neurones. L’implémentation est inspirée des anciennes versions de pytorch
(en Lua, avant l’autograd) et des implémentations analogues qui permettent d’avoir des réseaux génériques très modulaires. Chaque couche du réseau est vu comme un module et un réseau est constitué ainsi d’un ensemble de modules. En particulier, les fonctions d’activation sont aussi considérées comme des modules.
Notons
- celui du module par rapport aux paramètres
$∇_{W^h}M^h$ ; ce gradient est calculable sans connaître le reste du réseau, uniquement selon les caractéristiques du module ; - celui de l’erreur par rapport aux sorties du module
$∇_{z^h}L$ ; ce gradient est assimilable à l’erreur à corriger en rétro-propagation à la sortie du module, et est fourni par l’aval du réseau par induction (les modules$M^{h+1}, M^{h+2}, ...$ ). On note généralement les éléments de ce gradient$δ^h_j = \frac {∂L} {∂z_j^ h}$ .
Ainsi pour un module
avec
Ainsi, pour pouvoir utiliser la back-propagation, il suffit que chaque module puisse calculer sa dérivée par rapport à ses paramètres (utilisée dans l’équation 1) et sa dérivée par rapport à ses entrées (utilisée dans l’équation 2).
Cette section introduit le fonctionnement général de la librairie que vous allez développer. Elle est centrée autour de la classe abstraite Module qui représente un module générique du réseau de neurones.
La classe Module
contient :
- une variable
_parameters
qui stocke les paramètres du module lorsqu’il en a (la matrice de poids par exemple pour un module linéaire) ; - une méthode
forward(data)
qui permet de calculer les sorties du module pour les entrées passées en paramètre ; - une variable
gradient
qui permet d’accumuler le gradient calculé ; - une méthode
zero_grad()
qui permet de réinitialiser à 0 le gradient ; - une méthode
backward_update_gradient(input,delta)
qui permet de calculer le gradient du coût par rapport aux paramètres et l’additionner à la variable_gradient
- en fonction de l’entréeinput
et des δ de la couche suivantedelta
; - une méthode
backward_delta(input,delta)
qui permet de calculer le gradient du coût par rapport aux entrées en fonction de l’entréeinput
et des deltas de la couche suivantedelta
; - une méthode
update_parameters(gradient_step)
qui met à jour les paramètres du module selon le gradient accumulé jusqu’à son appel avec un pas degradient_step
.
Lorsque plusieurs modules sont mis en série, il suffit ainsi pour la passe forward d’appeler successivement les fonctions forward
de chaque module avec comme entrée la sortie du précédent. Pour la passe backward, le dernier module calcule le gradient par rapport à ses paramètres et les deltas qu’il doit rétro-propager (à partir des deltas du loss) ; puis en parcourant en sens inverse le réseau, chaque module répète la même opération : le calcul de la mise à jour de son gradient (backward_update_gradient
) et le delta qu’il doit transmettre à la couche précédente (backward_delta
).
Remarquez que les paramètres ne sont pas mis tout de suite à jour en fonction du gradient : celui-ci est d’abord accumulé dans la variable _gradient
et c’est uniquement lors de l’appel explicite à backward_update_gradient
qui provoque la mise-à-jour des paramètres. Cela rend plus flexible l’utilisation des modules (plusieurs passes de backward peuvent être calculées avant de mettre à jour les paramètres du fait de l’additivité du gradient).
La classe Loss
est plus simple : elle ne contient que deux méthodes :
- une fonction
forward(y,yhat)
qui permet de calculer le coût en fonction des deux entrées - une fonction
backward(y,yhat)
qui permet de calculer le gradient du coût par rapportyhat
.
Tout au long de votre implémentation, vous veillerez à réfléchir précisément à la taille des entrées et des sorties de chaque méthode. Il est conseillé d’utiliser l’instruction assert
pour vous assurer que les paramètres que vous passez en entrée sont de la bonne taille. Par ailleurs, votre implémentation devra pouvoir traiter à chaque fois un batch
d’exemples et non pas un seul exemple à la fois : ainsi la méthode forward
d’une module linéaire devra pouvoir prendre en entrée une matrice de taille
Code source :
from typing import Any
class Loss(object):
def forward(self, y, yhat):
pass
def backward(self, y, yhat):
pass
class Module(object):
def __init__(self) -> None:
self._parameters = None
self._gradient = None
def __call__(self, *args: Any, **kwds: Any) -> Any:
return self.forward(*args, **kwds)
def zero_grad(self):
self._gradient = 0
def forward(self, X):
pass
def update_parameters(self, gradient_step=1e-3):
self._parameters -= gradient_step * self._gradient
def backward_update_gradient(self, input, delta):
pass
def backward_delta(self, input, delta):
pass
Pour cette première étape, vous allez coder les deux classes dont vous avez besoin pour réaliser une régression linéaire :
- une fonction de coût
MSELoss
dont la méthodeforward(y,yhat)
doit rendre$∥y − \hat{y}∥^2$ ; Attention à la généricité de votre implémentation : la supervisiony
et la prédictionyhat
sont des matrices de taille batch × d (chaque supervision peut être un vecteur de taille d, pas seulement un scalaire comme dans le cas de la régression univariée). La fonction doit rendre un vecteur de dimension batch (le nombre d’exemples). - un module
Linear(input,output)
qui représente une couche linéaire avecinput
entrées etoutput
sorties. La méthodeforward
prend donc une matrice de taille batch × input et produit une sortie batch × output.
N’oubliez pas de coder toutes les fonctions de ces deux modules !
Implémentez le module TanH
qui permet d’appliquer une tangente hyperbolique aux entrées et le module Sigmoid
qui permet d’appliquer une sigmoïde aux entrées. N’oubliez pas que les modules de transformation héritent de la classe Module
et donc doivent implémenter les fonctions backward_update_gradient
, backward_delta
et update_parameters
même si le module n’a pas de paramètre !
Testez votre implémentation en réalisant un réseau à deux couches linéaires avec une activation tangente entre les deux couches et une activation sigmoïde à la sortie. Vous utiliserez des données d’un problème de classification binaire en considérant 1 et 0 comme classes positive et négative.
En réalisant le réseau à deux couches précédents, vous remarquez que les opérations de chaînage entre modules sont répétitives lors de la descente de gradient - que ce soit pour la passe forward ou backward - et qu’il sera fastidieux de les écrire pour un grand nombre de modules. Implémenter une classe Sequentiel
qui permet d’ajouter des modules en série et qui automatise les procédures de forward
et backward
quel que soit le nombre de modules mis à la suite.
Après l’avoir testé, vous pouvez implémenter une classe Optim(net,loss,eps)
pour condenser une itération de gradient : elle prend dans son constructeur un réseau net
, une fonction de coût loss
et un pas eps
. Elle contient une seule méthode step(batch_x,batch_y)
qui calcule la sortie du réseau sur batch_x
, calcule le coût par rapport aux labels batch_y
, exécute la passe backward et met à jour les paramètres du réseau.
Vous pouvez également implémenter une fonction SGD
qui prend en entrée entre autre un réseau, un jeu de données, une taille de batch et un nombre d’itération et s’occupe du découpage en batch du jeu de données et de l’apprentissage du réseau pendant le nombre d’itérations spécifié.
Le multi-classe utilise en sortie du réseau une dimension par classe pour dénoter la probabilité de chaque classe. Le vecteur de supervision est un encodage one-hot : un vecteur rempli de 0 sauf à l’index de la bonne classe qui prend la valeur 1. On utilise un Softmax que l’on introduit à la dernière couche pour transformer les entrées en distribution de probabilités grâce à la normalisation effectuée. On peut utiliser la MSE comme coût, cependant elle donne des résultats décevants car elle a tendance à trop "moyenner" les erreurs et ne pas pousser les sorties vers 0 ou 1. Il est mieux d’utiliser des coûts adaptés aux distributions de probabilités comme la cross entropie.
Pour pouvoir faire du multi-classe, nous avons donc besoin :
- d’une transformation
Softmax
qui permet d’appliquer un soft-max aux entrées :$\text{softmax}(z) = (\frac {e^{z_1}} {\sum_k e^{z_k}}, \frac {e^{z_2}} {\sum_k e^{z_k}}, ...)$ ; - d’un coût cross-entropique : pour
$y$ l’indice de la classe à prédire et$\hat{y}$ le vecteur de prédiction, le coût cross-entropique (équivalent à un maximum de vraisemblance) est$CE(y, \hat{y}) = −\hat{y}_y$ ; - il est habituel de combiner les deux ensembles afin d’éviter des instabilités numériques. Dans ce cas, on enchaîne un
Softmax
passé au logarithme (logSoftMax
) et un coût cross entropique. Le coût s’écrit alors : $CE(y, \hat{y}) = −\log \frac {e^{\hat{y}y}} {\sum{i=1}^K e^{\hat{y}_i}} = - \hat{y}y + \log \sum{i=1}^K e^{\hat{y}_i}$.
Testez sur le jeu de données des chiffres manuscrits par exemple.
Un auto-encodeur est un réseau de neurones dont l’objectif est d’apprendre un encodage des données dans le but généralement de réduire les dimensions. Il s’agit d’apprentissage non-supervisé : il n’y a pas de classes associées à chaque exemple lors de l’entraînement. La réduction de dimension dans ce cas à de multiples applications : débruitage d’image, reconstruction de parties cachées, clustering, visualisation... On peut également se servir de l’exemple encodé comme nouvelle description de l’exemple pour ensuite faire de l’apprentissage supervisé classique.
Concrètement, un auto-encodeur est formé de deux parties :
- un encodeur qui prend en entrée une donnée et la transforme dans un espace de plus petite dimension (à l’aide par exemple de couches linéaires successives de plus en plus petites, alternées avec des fonctions d’activation non linéaires) pour obtenir le code (ou représentation latente) de l’exemple ;
- un décodeur qui prend en entrée le code d’un exemple et généralement avec un réseau symétrique à l’encodeur décode l’exemple vers sa représentation initiale.
Un exemple d’architecture d’auto-encodeur :
- Encodage :
Linear(256,100)
→TanH()
→Linear(100,10)
→TanH()
- Décodage :
Linear(10,100)
→TanH()
→Linear(100,256)
→Sigmoid()
Dans cet exemple, une compression dans un espace de dimension 10 est réalisée à partir d’une entrée en taille 256. La sigmoïde finale permet de s’assurer que les sorties sont entre 0 et 1 (utile si c’est le cas pour la donnée d’entrée également).
L’entraînement se fait sur un coût de reconstruction :
La cross-entropie binaire pour une sortie y entre 0 et 1 permet d’être plus "abrupte" que la MSE sur les valeurs de sorties, i.e. de pousser les valeurs vers 0 ou 1 plutôt que vers un moyennage de valeurs comme le fait la MSE (quel rapport voyez vous avec la vraisemblance ?). Elle est appelée binaire car la supervision y dans ce cas est censée être binaire (soit 0 soit 1), dans les faits elle peut être utilisée sur des valeurs réelles compris entre 0 et 1. En pratique, on utilise également un seuillage (ou clip) du log pour ne pas avoir des valeurs infinies lorsque
Il est habituel d’utiliser pour le décodeur les matrices de poids transposées de l’encodeur afin d’assurer une régularisation (ce qu’on appelle un partage de poids dans le réseau), ce n’est pas demandé dans la suite.
Expérimentez ce type d’architecture avec les modules que vous avez développés en faisant varier les hyperparamètres (nombre de couches, taille de l’espace latent, fonctions d'activation, ...). Vous pouvez en particulier :
- visualiser les images reconstruites après une forte compression
- étudier le clustering induit dans l’espace latent (avec du k-means par exemple) et en utilisant les classes des exemples étudier la performance des représentations apprises
- visualiser les représentations obtenues dans un espace 2D ou 3D, soit directement si votre di- mension latente est de 2 ou 3, soit en utilisant un algorithme tel que t-sne pour projeter vos représentations en 2D
- étudier les performances en débruitage : bruiter les données et observer l’erreur entre la donnée débruitée par le réseau et la donnée originale. Vous pouvez également utiliser des données bruitées lors de l’apprentissage pour améliorer les performances.
- étudier les performances en classification en utilisant comme représentation la représentation latente construite : vous utilisez dans ce cas l’auto-encodeur comme un pré-traitement de vos données.
Il n’est pas demandé à ce que vous traitiez tous les sujets : choisissez une piste qui vous intéresse et montez un plan expérimental cohérent. Présentez l’objectif et les résultats auxquels vous parvenez.
La couche convolutionnelle est le standard en classification d’image. Elle permet d’appliquer un même filtre sur différentes positions de l’image et de sortir une valeur par position. Les sorties associées à un filtre sont une nouvelle image (de taille différente en fonction du nombre de position où l’opération de convolution a été effectuée) qui est appelée feature map.
Pour simplifier l’implémentation, nous nous intéresserons uniquement aux convolutions en 1 dimension. Nous supposerons donc notre image d’entrée aplatit dans une matrice de taille d × C, d = largeur × hauteur comme dans les parties précédentes. Le C correspond au nombre de canaux : pour une image en noir et blanc, il n’y en a qu’un, pour une image en couleur il y en a typiquement 3 (le codage le plus courant étant le codage RGB - rouge, vert, bleu). La notation xi,c désignera la valeur du i-ème pixel du canal c.
Un filtre de convolution est un opérateur
Le filtre peut être appliqué successivement sur tous les pixels d'une image. On obtient alors une sortie de taille
Une couche convolutionnelle est composée de plusieurs filtres de mêmes paramètres ksize et stride - ils produisent des vecteurs de même dimension en sortie - mais bien sûr de paramètres
L'autre type de couche utilisé en alternance avec les couches convolutionnelles est la couche de pooling qui permet de réduire la taille des feature maps considérées en effectuant un sous-échantillonage. Elle fonctionne comme un filtre mais au lieu d'appliquer une transformation linéaire, clle calcule une valeur aggrégée des valeurs présentes dans la fenêtre considérée : la moyenne, le maximum pour les cas les plus courants. Elle a donc également un paramètre ksize qui définit la taille de fenêtre considérée et un paramètre stride qui définit son déplacement dans l'image. Elle ne dispose pas d'autres paramètres. Dans le cas d'un max-pooling par exemple, la sortie
Il reste deux éléments à introduire pour pouvoir faire un réseau convolutionnel. Le premier est la fonction ReLU - (Rectified Linear Unit) que l'on préfère comme fonction d'activation aux autres usuelles : Flatten
qui permet de transformer une entrée 2D
En résumé, pour pouvoir réaliser un réseau convolutionnel, il faut coder :
- un module
Conv1D(k_size,chan_in,chan_out,stride)
qui contient une matrice de paramètres de taille(k_size,chan_in,chan_out)
(soitchan_out
filtres de taille(k_size,chan_in)
) ; sa méthodeforward
prend en entrée un batch de taille(batch,length,chan_in)
et sort une matrice de taille(batch, (length-k_size)/stride +1,chan_out)
. - un module
MaxPool1D(k_size,stride)
sans paramètres à apprendre. La méthodeforward
prend en entrée un batch de taille(batch,length,chan_in)
et sort une matrice de taille(batch,(length-k_size)/stride +1,chan_in)
. - un module
Flatten
qui prend en entrée un batch de taille(batch,length,chan_in)
et renvoie une matrice (batch, length * chan_in) ; - une fonction d’activation
ReLU
.
Implémentez les différents modules et expérimentez différentes architectures pour la reconnaissance des chiffres. À titre indicatif, un réseau de type Conv1D(3,1,32)
→ MaxPool1D(2,2)
→ Flatten()
→ Linear(4064,100)
→ ReLU()
→ Linear(100,10)
fait de très bonnes performances.
Les convolutions 1D implémentées permettent de reconnaître des motifs horizontaux dans les images. À partir de vos modules, il est très facile de reconnaître également des motifs verticaux : il suffit pour cela de transposer l’image. Vous pouvez donc implémenter un réseau qui reconnaît des motifs verticaux et horizontaux en concatenant la sortie de deux couches convolutionnelles. Vous pouvez également considérer du Average Pooling (qui fait la moyenne sur la fenêtre temporelle) plutôt que du Max Pooling. Les plus courageux peuvent enfin implémenter les "vraies" convolutions 2D. Ce n’est que quelques pistes possibles d’améliorations, cette partie est laissée libre à vos envies selon votre temps disponible (d’autres données images, d’autres types de données par exemple temporelles,...).
- pour transformer un vecteur d’indices y de classes en one-hot :
onehot = np.zeros((y.size,10)); onehot[np.arange(y.size),y]=1
- pour la partie convolutionnelle, vous aurez à utiliser
np.newaxis
qui permet d’ajouter une dimension à un tableau. - une initialisation aléatoire des poids entre -1 et 1 des couches convolutionnelles ménera potentiellement à des nombres trop grands en sortie (et donc à un NaN au passage de l’exponentiel). Si cela se produit, modifiez l’initialisation par un facteur 10−1 ou plus.
- pour le MaxPool, vous aurez besoin pour le gradient d’affecter des valeurs que aux endroits où
le max est atteint (et des zéros partout ailleurs). Une façon de faire est d’utiliser la même astuce que pour le one hot : si
idx
contient les indices de l’argmax sous forme aplatit, soit res la matrice résultat de taillenb_batch,length,chan
:res[np.repeat(range(nb_batch),chan),idx,list(range(chan))*nb_batch]
permet de toucher les indices voulues. - de manière générale, n’oubliez pas que le gradient est de la même taille que les paramètres et que
backward_delta
prend un delta de même taille que la sortie duforward
du module. - essayez de faire le moins de boucles possibles...