Modélisation pas-à-pas d’un réseau de neurones pour la classification

Je souhaitais m’attarder ici sur la procédure de modélisation d’un réseau de neurones pour la classification en Javascript. Comment choisir le nombre de couches intermédiaires et le nombre de neurones sur chacune ? Et surtout : comment interpréter les résultats ? Ce sont des questions que j’ai pu me poser et auxquelles j’aimerai appliquer une méthodologie.

J’utiliserai ma propre implémentation de réseau de neurones en JavaScript (Github) ainsi que des scripts Python pour générer et visualiser les données rapidement. Nous verrons, après l’explication de l’exemple ci-dessous, les points suivants :

  1. Génération des données
  2. Entraînement
    1. Étape 1: obtenir l’erreur la plus faible
    2. Étape 2: généraliser

Avertissement : j’apprends au fur et à mesure ; il se peut que certaines remarques se révèlent finalement être erronées. L’article a pour public des débutants comme moi qui ont du mal à interpréter l’impact de la topologie d’un réseau de neurones sur son apprentissage.

Accédez directement à la démonstration Javascript via JSFiddle, aussi plus bas dans la page.

L’exemple de classification

On souhaite que le réseau de neurones apprenne à comparer deux nombres (a, b) et retourne 1 si le premier est strictement supérieur au deuxième :
[f(a, b) = \begin{cases}1 & \text{si a \textgreater\ b}\ 0 & \text{sinon}\end{cases}]
J’ai fait le choix de ce problème simple pour que la procédure d’optimisation le soit également, tout en gardant à l’esprit que la conclusion sera applicable à des problèmes plus compliqués de classification.

Notre réseau de neurones artificiel prendra donc deux entrées et une seule sortie. Il nous reste néanmoins à trouver quelle topologie et quels hyperparamètres choisir:

  • Le facteur d’apprentissage learning rate
  • Le nombre de couches intermédiaires hidden layers
  • Le nombre de neurones par couche
  • La fonction d’activation des neurones des couches intermédiaires

I. Génération des données

L’élément déterminant ne fait pas pourtant pas partie de la liste ci-dessus. Le training dataset qui servira à entraîner le réseau de neurones doit être correctement choisi s’il on souhaite que l’apprentissage soit réussi. De même on utilisera un jeu de données de validation sur lequel le réseau n’apprendra pas pas de rétro-propagation pour s’assurer qu’il ne s’est pas juste adapté aux valeurs du jeu de données d’entraînement, c’est à dire éviter le surapprentissage (ou overfitting).

J’ai choisi de générer aléatoirement les couples (a, b) entre -0.5 et 0.5 plutôt que de générer tous les couples possibles (avec 0.01 de précision par exemple) car il est en pratique très rare de pouvoir prévoir tous les combinaisons d’entrées possibles et d’avoir un dataset exhaustif. A l’aide d’un script Python, je génère alors mes couples (a, b) selon les critères suivants :

  • Normalisé entre -0.5 et 0.5,
  • Tirage aléatoire uniforme et centré en 0.

Avec Python et la librairie NumPy, on peut utiliser a, b = np.random.uniform(-0.5, 0.5, 2). Voici les résultats pour différentes tailles de dataset, avec f(a, b) = 1 en rouge et f(a, b) = 0 en noir :

Représentation de différents jeux de données de différentes tailles n (généré avec Python).

La taille du jeu de données d’entraînement doit être suffisamment importante si nous voulons que le réseau de neurones ait assez d’exemples pour comprendre ce qu’on lui demande d’approximer. Plus le nombre de neurones de notre réseau croît et plus il est important de le nourrir d’un plus gros training dataset, afin d’éviter que chaque poids s’ajuste pour une valeur du dataset qu’il sur-apprenne.

Dans notre exemple, on voit que pour n=200 la démarcation n’est clairement pas aussi explicite que pour n=8000 ; par conséquent le réseau de neurones aura plus de mal à classifier avec cet entraînement. On préconise de répartir les tailles tel que le training set représente 80% et le validation set 20% de l’échantillon global. De manière générale, il est nécessaire que les valeurs soient également réparties sur les deux sets de données (variance égale) mais le validation set n’a pas besoin d’être aussi dense que le training set.

On prendra donc n=8000 pour l’entraînement et n=2000 pour la validation. Le code qui a permis la génération et la visualisation est disponible sur Github.

II. Entraînement

La librairie JavaScript nous permettra d’entrainer le réseau de neurones et de visualiser le résultat directement dans le navigateur. On choisit les hyper-paramètres suivants :

  • Un faible learning rate de 0.05. Une valeur qui revient souvent est 0.1, je néanmoins je préfère avoir un peu plus de précision avec celle-ci ;
  • On peut optimiser la recherche du gradient avec l’algorithme « Nesterov Accelerated-Gradient » qui permet une plus rapide convergence vers un faible taux d’erreur ;
  • La fonction d’activation sera PReLU avec \alpha = 0.1 car ReLU semble apporter la meilleure non-linéarité actuellement, mais souffre de « neurones morts » à l’approche de zéro (« dying relu problem » sur datascience.stackexchange.com) ;
  • Une fenêtre de 20 epochs qui suffiront pour ce simple problème et à la vue de notre dataset assez conséquent.

étape 1: converger vers un faible taux d’erreur

On souhaite en premier sous-estimer notre problème et obtenir un réseau qui n’apprend pas à cause d’un modèle trop simple (underfitting), puis rajouter au fur et à mesure des neurones et/ou couches intermédiaire pour diminuer le taux d’erreur. On peut commencer par un seul neurone sur une seule couche intermédiaire :

Avec une erreur autour de 0.05 et 0.04 le modèle possède déjà une précision d’un peu plus de 95%. Avec un layer de plus, l’erreur tombe à 0.3. Avec un second cela ne change pas grand chose: mais avec deux neurones sur les deux layers alors cette fois-ci l’erreur passe au-dessous de 0.001!

La courbe grise indique l’erreur relative au dataset de training, tandis que la courbe violette indique l’erreur relative au dataset de validation. Plus l’erreur est faible, plus la classification est précise.

En interagissant avec la démo JavaScript on s’aperçoit que la classification est très fiable, sauf pour le cas particulier où a = b. Rajouter quelques valeurs dans notre dataset où a = b devrait résoudre le soucis. Note : si la courbe de validation n’avait pas suivi la courbe de l’entraînement, alors on aurai potentiellement eu de l’overfitting.

Vous vous posez peut-être la question : qu’est-ce qui aurait changé si nous avions augmenté le nombre de neurones de chaque layer au lieu d’augmenter le nombre de layers lui-même ? D’après mes tests, pour atteindre la même performance que 2x2x2x1 avec un seul hidden layer il nous faut bien 6 neurones dessus, avec néanmoins moins de certitude que le réseau converge vers la bonne solution aussi rapidement. Avec 10, cette incertitude est écartée mais le calcul prend plus de temps et donc on perd en efficacité d’apprentissage.

En conclusion, il apparaît que grâce à la non-linéarité ajoutée par les couches intermédiaires le réseau de neurone peut apprendre plus rapidement et plus facilement qu’avec un grand nombre de neurones par couche. Par contre, trop de profondeur aura l’effet inverse sur un problème aussi simple.

étape 2: généraliser

Maintenant que nous avons un modèle robuste, nous aimerions savoir si notre réseau de neurones a vraiment appris à comparer deux nombres et non pas seulement s’ils sont compris entre -0.5 et 0.5.

En soit, il nous suffit dans notre cas de normaliser chaque entrée a et b entre -0.5 et 0.5 pour que la comparaison puisse fonctionner avec n’importe quels nombres. Mais c’est l’idée de la généralisation qui nous intéresse, car le but même de l’intelligence artificielle est de pouvoir faire au final abstraction des données d’entraînement pour correctement évaluer des données jamais vues.

Visualisation du set de test (n=600), qui ne possède pas de valeurs entre -0.5 et 0.5

Sur l’expérimentation, vous pouvez choisir l’intervalle à [-5 ; 5] pour constater que le réseau n’arrive pas à gérer ces valeurs.

Nous générons donc un troisième jeu de données : le dataset de test qui lui tirera des couples (a, b) dans l’intervalle [-5 ; 5], en excluant toute valeur de ]-0.5 ; 0.5[. Cette fois-ci encore, on n’appliquera pas la backpropagation sur ce dataset.

Sa taille n’est pas importante et nous ne voulons pas ralentir l’apprentissage, 600 couples (a,b) seront suffisants. Le JSFiddle aussi plus bas dans cet article vous permettra de visualiser comment le réseau de neurones réagit au set de test (courbe verte).

Avec une topologie 2x2x2x1, la courbe ne se voit tout simplement pas car l’erreur est trop importante (supérieure à 1). En multipliant par 2 le nombre de neurones par couche intermédiaire, la courbe est présente mais vacille trop : on ne peut toujours pas dire que notre réseau a généralisé. Même en passant à 6 ou 12 neurones par couche, le résultat reste sensiblement le même donc augmenter le nombre de neurones ne semble pas être la solution.

La courbe verte représente l’erreur sur le dataset de test. Plus l’erreur est faible, plus l’apprentissage est généralisé.

Néanmoins, avec un layer supplémentaire on arrive à allier une seconde fois l’abstraction apportée par une couche supplémentaire, avec les possibilités offertes par plus de neurones.

La topologie 2x4x4x4x1 semble être celle qui permet le plus de généralisation.

Voici le résultat, que vous pouvez modifier vous-même avec JSFiddle :

Vous remarquerez que la convergence vers l’erreur minimale n’est pas automatique. La durée d’entraînement de 20 epochs est relativement faible et nous pourrions laisser plus de temps à l’optimiseur (Nesterov Accelerated Gradient) avec 100 ou 200 epochs, ce que je vous invite à tester.

En conclusion, dans cet exemple ajouter plus de neurones permet plus de combinaisons et donc plus de possibilités, tandis qu’ajouter des couches intermédiaires permet de faire sens de ces possibilités.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *