Implémentation d’un réseau de neurones en Javascript à travers un exemple

Les réseaux de neurones sont un sujet passionnant que je voulais expérimenter après avoir abordé les algorithmes génétiques. J’explique ci-dessous comment j’ai implémenté un réseau neuronal en Javascript, à travers un exemple visuel pour mieux appréhender la notion d’apprentissage automatique.

Vous pourrez trouver le code complet de l’exemple et de l‘implémentation du réseau neuronal sur Github, ainsi que la démo complète sur JSFiddle. L’article se divise en trois parties :

  1. Le fil rouge : un cercle qui apprend à suivre la souris
  2. Comment rendre le modèle performant
  3. L’implémentation en Javascript

Si l’intelligence artificielle et les réseaux de neurones sont de nouvelles notions pour vous voici quelques vidéo ressources que je recommande à propos des réseaux de neurones :

Un cercle qui apprend à suivre la souris

L’exemple n’est pas un exemple de classification, mais plutôt de régression linéaire. J’ai dessiné un cercle dans un canvas HTML5 et je souhaite maintenant que ce cercle apprenne à suivre les mouvements de la souris jusqu’à calquer complètement sa position. J’aimerai envoyer en entrée de mon intelligence artificielle la position de la souris et récupérer en sortie la position de mon cercle :

Illustration de l'apprentissage dans notre exemple fil rouge
Illustration de l’exemple fil rouge et du résultat souhaité

Autrement dit, le réseau de neurones (Neural Network, ou NN) doit apprendre à sortir en output son propre input. Mathématiquement, on peut dire que l’on essaye d’approximer f([x, y]) = [x', y'] ou la ‘fonction identité’. Dans la littérature, ce genre de réseau de neurones est un auto-encodeur particulier (où nous ne faisons pas de réduction de dimension).

L’avantage de cet exemple, c’est qu’on est sûr que le problème est résolvable avec deux neurones en entrée et deux en sortie. On utilisera tout de même au moins une couche intermédiaire (hidden layer) sinon ça n’a pas d’intérêt. Le but du jeu est de mettre en place la rétro-propagation et de comprendre vraiment pas à pas comment le réseau modifie ces poids (weights) pour arriver à ses fins.

Les étapes d’entraînement du réseau de neurones

  1. La souris change de position
  2. Propagation avant (feedforwarding) : le NN calcule la nouvelle position du cercle
  3. Rétro-propagation (backpropagation) : le NN ajuste ses poids pour réduire la différence entre la position du cercle et la position de la souris
  4. Recommencer

Dans l’idée, la position du cercle devrait au fur et à mesure tendre vers la position de la souris. L’expérimentation doit avant tout permettre de visualiser cet apprentissage puisque lorsque le NN converge vers sa solution, le cercle converge visuellement vers l’emplacement de la souris. Voici le résultat en vidéo :

Dans cette vidéo, j’aborde l’entraînement du réseau de neurones en 2 partie : une première en live-training (l’utilisateur doit bouger la souris pour que le réseau de neurone apprenne sous ses yeux) et la deuxième avec un training au préalable. Aparté : l’entraînement de la première partie est plus longue dans la vidéo qu’actuellement, je vous invite donc à tester plus bas dans la page l’expérimentation  pour voir la différence de performance (ou directement sur JSFiddle). C’est en écrivant l’importance sur la normalisation (partie implémentation) que j’ai remarqué pouvoir améliorer les performances de l’apprentissage.

Penchons-nous sur le réseau de la première partie :

Visualisation des poids du réseau de neurones entraîné

Ce qui est intéressant, c’est de visualiser comment le réseau a finalement « réparti » ses deux entrées vers la couche intermédiaire, qui elle va « réassembler » ces deux entrées pour les envoyer en sortie. On peut presque tracer un chemin en regardant les poids les plus importants :

En identifiant les poids principaux du réseau de neurones, deux chemins se distinguent

En vérifiant on réalise bien que 0.4 * 0.9165 * 1.1456 = 0.4199... tandis que -0.3 * 0.8663 * 1.2139 = -0.315.... Ce qui est intéressant de voir, c’est comment le NN se modifie lui même pour se concentrer sur un poids au lieu des deux, et comment il fait abstraction des biais (valeur entre -1 et 1 ajoutée au calcul de chaque neurone). De même, ces combinaisons de poids ne donnent pas exactement 0.4 ou -0.3 car ils corrigent aussi les parasites engendré par les autres poids, insignifiants.

L’analogie du tamis

Par contre, avec plus de neurones et davantage de couches (par exemple 2 • 6 • 6 • 6 • 2), il est beaucoup plus dur d’identifier un chemin donné étant que les deux informations x et y sont beaucoup plus éparpillées entre les neurones.

S’il fallait faire une analogie, alors je dirai que dans notre cas le NN est un formidable empilement de tamis auquel on passe des grains de sable : bien différenciés bleus et verts avant le premier tamis, les deux types de grains se mélangent dans les couches intermédiaires mais finissent à nouveau séparés par leur couleur en sortie du dernier tamis.

 

Vous pouvez vous même tester l’expérimentation grâce au JSFiddle ci-dessous :

Notez qu’il est possible de faire varier les hyperparamètres du NN et changer sa structure.

Comment rendre le modèle performant

Intéressons nous maintenant à l’élaboration de notre réseau de neurones. Il est possible d’améliorer son apprentissage et d’agir sur la rapidité de convergence du cercle vers la souris en ajustant les hyperparamètres du NN :

  • Le facteur d’apprentissage (learning rate) qui est un facteur qui intervient dans les calculs du gradient, au cœur de la backpropagation ;
  • La fonction d’activation (activation function) qui « filtre » la valeur en sortie d’un neurone ;
  • Le nombre de hidden layers , et la quantité de neurones sur chaque layer.

Ces paramètres permettent d’améliorer la performance du modèle mais le plus déterminant est encore le choix des données en entrée et ce que l’on souhaite en sortie.

L’importance de la normalisation

Dans notre cas il n’y a pas vraiment à réfléchir sur la nature des données en entrée et sortie : nous avons choisi d’avoir en input les coordonnées de la souris et en output celles de notre cercle parce que c’est directement ce que nous avons de disponible (en entrée) et ce que nous voulons (en sortie).

Par contre, il ne s’agit pas d’envoyer directement les coordonnées de la souris sans au préalable les normaliser. En sachant que l’erreur en sortie (l’erreur quadratique moyenne ou mean squared error) est calculée telle que mse = 1/2 * (target - output)², avec target notre valeur cible, il est nécéssaire que (target - output)² soit borné entre 0 et 1 inclus peu importe output afin d’éviter que l’erreur croisse de façon exponentielle. Pour ça, et sachant que notre repère est centré, on divise les valeurs d’abscisse par la moitié largeur du canvas et les valeurs d’ordonnées par la moitié de sa hauteur.

Je vous invite à tester de normaliser avec 2 fois la norme sur chaque axe : vos valeurs seront donc comprises dans l’intervalle [-0.5, 0.5] et le réseau de neurones apprendra bien moins vite à cause des variations moins importantes et donc moins « quantifiées » par l’erreur quadratique.

Sans normalisation, on obtient rapidement des calculs d’erreurs incohérents lors de la rétro-propagation : les erreurs explosent, les poids aussi et rapidement le réseau sature. Également, même si dans l’exemple la fonction d’activation est linéaire, dans bien des cas (tanh, sigmoid, etc…) il n’y a aucune différence entre 2 grandes valeurs de plus de 1. Par exemple, aux coordonnées (300, 200) <=> (0.257, 0.171) normalisé on a :

  • tanh(300) = 1 alors que tanh(0.257) = 0.251
  • tanh(200) = 1 alors que tanh(0.171) = 0.169

D’où l’importance de normaliser ses valeurs en entrée, mais également préférer des valeurs en sortie entre -1 et 1 pour éviter la propagation d’une erreur trop grande.

Influence du learning rate

Globalement, je fais souvent varier à tâtonnement le learning rate sur une échelle logarithmique : 0.5, 0.05, 0.005, etc.

Il est largement possible de visualiser l’impact d’un learning rate trop faible sur l’apprentissage (beaucoup plus lent) : le cercle peine à suivre la souris et le training en « live » peut mettre beaucoup de temps. En utilisant le pré-training, la différence se remarque néanmoins à peine.

De même, un learning rate trop grand empêche le réseau de neurones d’aller jusqu’à la fin de son apprentissage à cause de gradients trop importants dans la backpropagation. Cela arrive notamment lorsqu’il y a beaucoup d’intermédiaire entre l’input et l’output (autrement dit quand il y a de nombreux hiddens layers et neurones).

Choisir la fonction d’activation

Je dois dire que je n’ai pas mis en place beaucoup de différentes fonctions d’activation sur les couches intermédiaires (seulement sigmoid, tanh et ReLU) et mes tests ont montrés que pour résoudre ce problème, le réseau de neurones fonctionne terriblement mieux avec une simple fonction linéaire. Tanh a montré des résultats, néanmoins pas très intéressants à cause d’un temps d’apprentissage bien plus long que le linéaire. Sigmoid et ReLU n’ont montré aucuns résultats.

Et cela fait sens : sigmoid fonctionne mal car nous travaillons dans l’intervalle [-1, 1] alors que l’ensemble d’arrivée de sigmoid est [0, 1[. De même, ReLU ne tient pas compte de nos valeurs négatives. Ces fonctions d’activation fonctionnent sûrement mieux sur des problèmes de classification mais limitent trop les valeurs sur un problème de régression linéaire.

Influence du nombre de neurones

L’analogie du tamis ci-dessus est encore plus impressionnante (presque magique) lorsqu’on augmente le nombre de couches et de neurones. Concrètement, à cause de la simplicité de notre exemple il n’y aucun intérêt à augmenter le nombre couches intermédiaires à part pour rajouter de la complexité de calcul et de la lenteur.

Néanmoins, c’est toujours impressionnant de visualiser comment le réseau arrive tout de même à trouver une solution même avec une topologie un peu plus « deep » comme 2 • 6 • 6 • 6 • 6 • 6 • 6 • 6 • 2. Dans ce cas, il n’est plus vraiment possible d’entraîner en live comme dans la vidéo, sauf si vous avez beaucoup de temps… N’oubliez pas d’activer la backpropagation.

L’implémentation en JavaScript

Tout cet exemple a donc été le fil rouge pour effectuer ma propre implémentation d’un réseau de neurones en JavaScript. Le choix de JavaScript est simplement pour avoir un fonctionnement dans le navigateur et la possibilité de créer une visualisation interactive.

Finalement, c’est le point de départ d’une implémentation réutilisable -ou mini librairie- pour d’autres projets, dont vous pouvez trouver l’intégrité du code sur Github.

Pourquoi ne pas avoir utilisé une librairie existante ?

J’ai bien testé ConvNetJS écrit par Andrej Karpathy, mais impossible de la faire fonctionner au départ, par manque de connaissance. J’avais besoin de mettre les mains dans le cambouis moi-même pour réellement savoir manipuler une librairie de réseau de neurones. Je n’ai pas réessayé d’utiliser la lib ou une autre depuis, ça serait néanmoins intéressant de comparer les résultats et performances avec mon implémentation. Mais j’ai également appris que chaque lib (peu importe le langage / la plateforme) fait ses choix d’implémentation des algorithmes et qu’il peut y avoir des différences significatives selon les choix, ça rend la comparaison difficile à faire.

J’ai souhaité que cette implémentation reste simple et concentrée autour de l’idée basique des algorithmes principaux (feedforwarding, backpropagation, etc….) afin qu’un autre débutant puisse, en lisant le code, comprendre rapidement. Par contre, la mise en place d’un Web Worker pour faire le pre-training était nécessaire pour ne pas bloquer le thread principal dans le navigateur, et donc avoir une meilleure expérience.

Structure des données

Neurones et réseau sont sous la forme d’objets prototypés :

function Neuron(id, layer, biais) {

    this.id = id;
    this.layer = layer;
    this.biais = biais || 0;
    this.dropped = false;

    this.output = undefined;
    this.error = undefined;

    this.activation = undefined;
    this.derivative = undefined;
};

////////////////////////////////////////////

function Network(params) {

    // Required variables: lr, layers
    this.lr = undefined; // Learning rate
    this.layers = undefined;
    this.hiddenLayerFunction = undefined; // activation function for hidden layer

    this.neurons    = undefined;
    this.weights    = undefined;
    
    
    // ... load params
}

Poids et neurones sont stockés dans 2 tableaux de dimension 1 pour des raisons de rapidité et flexibilité : je ne trouve pas que les tableaux à plusieurs dimensions sont toujours les plus simples à manipuler et les opérations sur un tableau à une dimension sont généralement plus optimisés par les moteurs de rendu JS.

Tirage aléatoire

Les poids et biais sont initialisés entre -1 et 1. D’après mes tests, le tirage des poids n’a pas trop d’influence car dans la majorité des cas tous convergent vers leur bonne valeur assez rapidement (dépendamment du learning rate).

function randomBiais() {
    return Math.random() * 2 - 1;
}

function randomWeight() {
    return Math.random() * 2 - 1;
}

Par contre, il est bien nécéssaire de prendre en compte les biais comme des poids dans la rétro-propagation et d’ajuster également leur valeur pour que le réseau arrive à une solution (puisque la fonction à approximer est la fonction identité, et que nous n’avons pas besoin de constantes supplémentaires dans les équations). De ce fait, il est possible pour cet exemple fil rouge de désactiver les biais (randomBiais = () => 0;).

Feedforward et backpropagation

La propagation est simple. La seule « optimisation » implémentée est d’éviter de récupérer pour chaque neurone le tableau des neurones de la couche précédente. Ce tableau est conservé jusqu’à un changement de couche (lignes 14/15 ci-dessous). Je traite d’abord les neurones inputs comme cas particuliers, puis feed-forward en parcourant tous le tableau de neurones (d’où l’intérêt d’avoir un tableau une dimension) depuis la deuxième couche:

// Input layer filling
for (index = 0; index < this.layers[0]; index++)
    this.neurons[index].output = inputs[index];

// Fetching neurons from second layer (even if curr_layer equals 0, it'll be changed directly)
for (index = this.layers[0]; index < this.nbNeurons; index++)
{
    neuron = this.neurons[index];

    if (neuron.dropped)
        continue;

    // Update if necessary all previous layer neurons. It's a cache
    if (prev_neurons === undefined || neuron.layer !== curr_layer)
        prev_neurons = this.getNeuronsInLayer(curr_layer++);

    // Computing w1*x1 + ... + wn*xn
    for (sum = 0, n = 0, l = prev_neurons.length; n < l; n++) {
        if (!prev_neurons[n].dropped)
            sum += this.getWeight(prev_neurons[n], neuron) * prev_neurons[n].output;
    }

    // Updating output    
    neuron.output = neuron.activation(sum + neuron.biais); 
}

L’implémentation de la rétro-propagation est plus dense. Encore une fois, j’ai préféré traiter d’abord les neurones particuliers (ceux en sortie) pour calculer l’erreur, puis d’itérer sur chaque neurone pour recalculer chaque poids :

// Output layer error computing: err = (expected-obtained)
for (n = 0, l = outputs_neurons.length; n < l; n++)
{
    neuron = outputs_neurons[n];
    grad = neuron.derivative(neuron.output);
    err = targets[n] - neuron.output;
    neuron.error = grad * err;
    output_error += Math.abs(neuron.error);

    // Update biais 
    neuron.biais = neuron.biais + this.lr * neuron.error;        
}

this.outputError = output_error;

// Fetching neurons from last layer
for (index = this.layersSum[curr_layer-1] - 1; index >= 0; index--)
{
    neuron = this.neurons[index];

    // Dropping neuron is a technique to add dynamic into training
    if (neuron.dropped)
        continue;

    // Update if necessary all next layer neurons. It's a cache
    if (next_neurons === undefined || neuron.layer !== curr_layer)
        next_neurons = this.getNeuronsInLayer(curr_layer--);

    // Computing w1*e1 + ... + wn*en
    for (sum = 0, n = 0, l = next_neurons.length; n < l; n++) {
        if (!next_neurons[n].dropped)
            sum += this.getWeight(neuron, next_neurons[n]) * next_neurons[n].error;
    }

    // Updating error    
    neuron.error = sum * neuron.derivative(neuron.output); 
    this.globalError += Math.abs(neuron.error); 

    // Update biais
    neuron.biais = neuron.biais + this.lr * neuron.error;

    // Updating weights w = w + lr * en * output
    for (n = 0, l = next_neurons.length; n < l; n++)
    {
        if (next_neurons[n].dropped)
            continue;
        weight_index = this.getWeightIndex(neuron, next_neurons[n]); 

        // Update current weight
        weight = this.weights[weight_index] + this.lr * next_neurons[n].error * neuron.output;

        // Update maxWeight (for visualisation)
        max_weight = max_weight < Math.abs(weight) ? Math.abs(weight) : max_weight;

        // Finally update weights
        this.weights[weight_index] = weight;
    }
}

Vous pouvez trouver l’implémentation complète sur Github à Network.prototype.feed() et Network.prototype.backpropagate()

Training

Comme dit plus haut, j’ai utilisé l’API WebWorkers pour effectuer le training du réseau de neurones dans un thread séparé, le but étant de ne pas bloquer la page pendant le traitement. Vu que la mémoire n’est pas partagée et que je n’utilise pas un SharedWorker, il faut donc copier l’intégralité du réseau de neurones et du dataset de training :

  1. Copie des paramètres et du dataset de training vers le worker
  2. Recréation du réseau de neurones dans le worker
  3. Entraînement sur le dataset (training)
  4. Copie retour des paramètres si modification, des poids et biais
  5. Mise à jour du réseau de neurone principal avec les nouveaux paramètres, poids et biais.
////////////////////// Main thread:

// Start web worker with training data through epochs
worker.postMessage({
    params: this.exportParams(),
    weights: this.exportWeights(),
    biais: this.exportBiais(),
    training_data: training_data,
    epochs: epochs
});

////////////////////// Worker:

// Create copy of our current Network
var brain = new Network(e.data.params);
brain.weights = e.data.weights;

// ...

// Feedforward NN
for (curr_epoch = 0; curr_epoch < epochs; curr_epoch++)
{
    for (sum = 0, i = 0; i < training_size; i++)
    {
        brain.feed(training_data[i].inputs);
        brain.backpropagate(training_data[i].targets);

        sum += brain.outputError;
    }
    
    global_sum += sum;
    mean = sum / training_size; 
    global_mean = global_sum / ((curr_epoch+1) * training_size); 

    // Send updates back to real thread
    self.postMessage({
        type: WORKER_TRAINING_PENDING,
        curr_epoch: curr_epoch,
        global_mean: global_mean,
    });
}

/////////////////////// Main thread:

// Training is over: we update our weights and biais
if (e.data.type === WORKER_TRAINING_OVER)
{
    that.importWeights( e.data.weights );
    that.importBiais( e.data.biais );

    // Feeding and bping in order to have updated values (as error) into neurons or others
    that.feed( training_data[0].inputs );
    that.backpropagate( training_data[0].targets );
}

Vous pouvez trouver l’implémentation complète sur Github à Network.prototype.train() et Network.prototype.workerHandler()

Prochaine étape

Comme indiqué au début de l’article, les réseau de neurones sont pour moi une continuation logique des algorithmes génétiques, et il serait intéressant de déterminer les hyperparamètres optimaux du réseau en appliquant un algorithme génétique.

La prochaine étape est de modifier cette implémentation pour modéliser un réseau de neurones récurrent, un exemple à l’appui. Stay tuned!

 

Laisser un commentaire

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