Motivations

Cet article avait été publié une première fois sur Dev To en anglais, en novembre 2020, époque à laquelle j’avais commencé à m’intéresser aux réseaux de neurone. La question était de savoir comprendre comment fonctionnent les réseaux de neurone au plus bas niveau.

Préambule

Je ne suis jamais devenu un expert du domaine, mais d’un côté j’ai un passif de développeur, et de l’autre côté, je m’intéresse également (en amateur) aux neurosciences. C’est donc tout naturellement que je fut curieux de comprendre comment fonctionnent un réseau de neurone le plus simple du monde, codé à la main, au lieu de passer par des libs d’une puissance considérable et fournissant une abstraction (très) haut niveau de ces mécaniques.

Fatalement, j’aurai certainement pris un paquet de raccourcis qui vont hérisser le poil des experts, notamment côté maths (mea culpa).

Notez aussi qu’il existe pléthore de types de réseaux de neurones différents les uns des autres. Ici, on tests l’un des types les plus simples de la Terre : le « Feedfoward Neural Network » avec « Back propagation », ou « Réseau de neurone à action directe, avec rétro-propagation du gradient » … Un nom à coucher dehors, mais au moins tout est dit, n’est-ce pas ?

Comment ça, non ?

Bon, si vous lisez la suite, vous comprendrez (un p’tit peu) en quoi ça consiste par la pratique, mais en gros : les données arrivent en entrée et sont transmises jusqu’à la sortie, avec éventuellement quelques traitements par les neurones. Et « rétro-propagation du gradient » ça veut dire qu’on va comparer la sortie du réseau avec ce qu’on voulait, et on va remonter l’information de la sortie vers l’entrée pour mettre à jour les neurones.

À noter que les pythonistes aussi verseront quelques larmes en lisant mon code, j’ai fait des trucs pas bien … Promis, le jour où j’en ai le temps, j’améliorerai ça ;-) En contre partie, ils aurons un avantage sur tout les autres : ils ont déjà tout ce qu’il faut pour tester mon exemple de réseau de neurones.

Pour ceux qui sont pressés

Autrement nommé « TL;DR ».

Vous pouvez retrouver le script python avec mes petits utilitaires python, à l’adresse suivante : framagit.org/Meier-Link/py-utils/neuron.py.

Ce script contient un ensemble de classes, notamment Neuron, Layer et Network, et quelques tests qui seront déroulés si vous lancez le script directement depuis votre terminal (i.e. cd chemin/du/script && ./neuron.py). Vous pourrez aussi bidouiller les tests (ils sont en bas, en dessous du if name == ‘__main__’que tout pythoniste se respectant reconnaitra ; et trouvera moche), ou carrément lire les commentaires dans le code, que j’ai essayé de rendre le plus complet possible.

Ok, c’est bien gentil, mais … C’est quoi ce truc ?

(oui, je vais plus ou moins copier/coller l’article d’origine, mais en version française … Je fais ce que je veux, j’en suis l’auteur x))

Alors je vais faire l’impasse sur les pythonisteries (je viens de l’inventer, ça sonne bien ?) pour passer directement au vif du sujet, c’est-à-dire les composants de base de notre réseau de neurones :

  • La classe Neuron,
  • La classe Layer, et enfin
  • la classe Network.

Le neurone, la brique de base du cerveau

Qu’il soit dans notre tête, ou dans celle d’un ordinateur, le neurone est un outils qui prend des valeurs d’entrée, fait des traitements dessus, et renvoie une sortie (ou plusieurs sories, ça marche aussi).

Le neurone peut ensuite se « mettre à jour » (ça c’est le mot pour un neurone virtuel). C’est la base de la rétro-propagation.

En entrée

En entrée, on passera soit des données de l’environnement (la température, la pression, ou une image qui représente soit un chien, soit un cookie - vieille référence, maintenant o_O), ou bien des données d’un autre neurone (ou ensemble de neurones. Oui on a dit qu’il pouvait y avoir plusieurs entrées, tout à l’heure).

La transformation

Ce qu’il se passe à l’intérieur du neurone, ce sont des maths. Alors c’est là que les experts (ou ceux qui veulent le devenir) vont me faire la tête, parce que les vraies maths, ici, me dépassent totalement … Je m’en suis tenu à la fonction mathématique la plus simple de la Terre, (enfin, presque) : y = ax + b.

Avec :

  • xest la (ou les) valeur(s) d’entrée ;
  • aest la pondération de la fonction (en temps normal, on écrit «w») ;
  • best le biais (comme dans « biais cognitif ») ;
  • yest la valeur de sortie du neurone.

La pondération et le biais sont des valeurs qu’on défini à l’avance, mais le but, c’est qu’ils soient mis à jour pour obtenir le résultat attendu après entraînement (vous savez, l’histoire de « rétro-propagation du gradient » ? Oui, c’est cool pour briller en société … Enfin, en théorie, moi ça n’a jamais marché).

Alors en principe, la transformation utilise une « Fonction.org/d’activation ». L’idée, c’est qu’arrivé à un certain seuil (seuil de stimulation), le neurone va commencer à répondre.

Bon, honnêtement, si vous voulez en savoir plus, cliquez sur le lien Wikipédia, ça ira plus vite x)

La sortie

La valeur de retour de notre neurone va être à son tour transmise à un autre neurone, ou vers le monde extérieur (enfin, ça peut rester dans notre boîte cranienne, si on parle du cerveau humain), par exemple pour dire si on est en train de regarder un cookie ou un chient (vous vous rappelez, la référence passée de mode ?).

Et dans tout ça, il se met à jour quand, le neurone ?

Ah effectivement, on l’a juste évoqué, cette partie là, mais pas encore développé. Quand le réseau renvoi une valeur, on peut la comparer avec ce qu’on aurait voulut (genre dire « là, c’était un chien, pas un cookie ! »). Il s’agit alors de calculer la différence, en utilisant ce qu’on appelle une « Fonction objectif https://fr.wikipedia.org/wiki/Fonction_objectif » (celle là je ne m’y attendait pas, on dit “Loss Function” en anglais).

Bon, en principe, la fonction objectif, c’est un truc compliqué. Mais nous, on va faire une simple soustraction du résultat attendu sur le résultat obtenu. Cette valeur va servir à mettre à jour le neurone, c’est-à-dire son biais et sa pondération. Bon, dans notre cas, la mise à jour est simplifiée comme suit : nouvelle_valeur = ancienne valeur + (objectif * vitesse_apprentissage).

Bon, on retrouve bien l’ancienne valeur, la nouvelle valeur, l’objectif … Mais là, on voit apparaître un « vitesse d’apprentissage » ? Ben en fait, comme son nom le laisse entendre, c’est la vitesse à laquelle notre réseau de neurone va apprendre. Si la valeur est trop élevé, notre neurone va apprendre à la vitesse de l’éclaire, mais il manquera cruellement de précision (il aura beaucoup de mal à tendre vers la bonne valeur). Et si sa valeur est trop faible … Ben notre neurone va apprendre lentement, mais sûrement (La Fontaine nous aurait tout de suite rappelé la fable du lièvre et de la tortue).

Et quand on met tout bout à bout …

Bon, ça nous a déjà fait un joli pavé de théorie. Maintenant, ça donne quoi, en Python, bête et méchant ?

class Neuron:
    """A simple neuron"""
    def __init__(self, weight:float, bias:float):
        """Create a neuron.

            We always initialize a neuron with a weight and bias.
        """
        self._w = weight
        self._b = bias
        self._result = None

    @property
    def result(self):
        return self._result

    def process(self, data:float):
        """Process given input."""
        self._result = self._w * data + self._b

    def update(self, expected:float, learning_rate:float):
        """The learning process.

            The expected value and learning rate are given on the update step.
        """
        loss = expected - self._result
        self._w = self._w + (loss * learning_rate)
        self._b = self._b + (loss * learning_rate)py

Une simple couche de neurones

Ce qu’on appelle en anglais un “layer” est vraiment une « simple » couche de neurones. Rien de plus. On y retrouve les mêmes méthodes que sur la classe “Neuron” que nous avons vu plutôt. Elle va en effet appliquer les mêmes processus sur l’ensemble des neurones.

Par contre, il y a quelques subtilités à prendre en compte :

  • tout les neurones d’une couche donnée ont la même pondération et le même biais ;
  • tout les neurones prennent l’intégralité des données d’entrée de la couche ;
  • chaque neurone de la couche fournit sa propre sortie.

class Layer:
    """Really a simple list of neurons"""
    def __init__(self, weight:float, bias:float, size:int):
        """Create the layer"""
        self._neurons = [Neuron(weight, bias) for _ in range(size)]

    @property
    def neurons(self):
        """Make possible to see the neurons inside that layer."""
        return self._neurons

    def process(self, data_set:Sequence[float]):
        """Give to the neurons the data to process."""
        for n in self._neurons:
            n.process(data_set)

    def update(self, expected:Sequence[float], learning_rate:float):
        """Update each neuron of the layer."""
        assert len(expected) == len(self._neurons)
        for n, e in dict(zip(self._neurons, expected)).items():
            n.update(e, learning_rate)

\ Peut-être avez-vous remarqué que notre couche fournit une liste de donnée (data_set, l’intégralité des données, comme on disait plus haut) à chaque neurone, alors que l’exemple de neurone donné plus tôt ne prend qu’une valeur, n’est-ce pas ?

Promis, ce n’est pas une erreur. C’est juste qu’on doit apporter une petite mise à jour à notre neurone …

Je ne sais pas si vous vous en souvenez (ouais cet article est un peu long x)), je vous disait que nous travaillons avec un « réseau de neurone à action directe ». En fait, on travaille avec un réseau qui a une autre propriété, il est « entièrement connecté » (“Fully Connected Neural Network” en english). C’est un type de réseau qui sont très utilisés pour des tâches de classification de données, par exemple. Ils servent aussi de composant de base à des réseaux de neurone nettement plus complexe (plus que ceux que j’ai compris à ce jour, en tout cas).

Du coup, « entièrement connecté », c’est juste pour dire que chaque neurone d’une couche donnée prend l’intégralité des données de la couche précédente.

C’est tout.

Oui oui, vraiment tout.

Bon du coup, si on doit modifier notre neurone de tout à l’heure, on doit faire en sorte que sa méthode “process” ne prenne plus juste une valeur, mais une liste de valeur. On s’attend ici à ce que toutes ces valeurs soient correlées entre elles (pensez aux pixels d’une image, par exemple, tous encodés avec des chiffres hexadécimaux). Et bien entendu, on doit appliquer la pondération et le biais, ce qui nous donne la formule suivante :

class Neuron:
    # ...
    def process(self, data:Sequence[float]):
        activated = sum([(self._w * d) for d in data]) / len(data)
        self._result = activated + self._b
    # ...

Et voilà, nos neurones peuvent être rassemblés en couches :D

Mais … Ce n’est pas fini …

LE réseau

Et là, ça commence à être plus complexe …

(Comment ça, ça l’était déjà ?)

Pour commencer en douceur, on va juste dire que le réseau, se résume à une liste de couche, comme une couche est une liste de neurone.

Vu que c’est un peu plus compliqué, on va commencer par regarder la tête du constructeur de réseau :

class Network:
    """A simple fully connected network."""
    def __init__(self, layers_conf:Sequence[], learning_rate:int):
        """Create the network."""
        self._lr = learning_rate
        self._layers = []
        for layer_conf in layers_conf:
            self._layers.append(Layer(**layer_conf))

Ouais, alors il y a quand même une petite subtilité à rajouter, c’est que la première couche de notre réseau, elle prend les données de l’environnement, qu’elle va ensuite fournir à la première couche interne du réseau (ce qu’on appelle les “hidden layer”, ou couches cachées). Donc chaque neurone de notre première couche va prendre une donnée, pour ensuite fournir ces données aux couches internes du réseau.

Du coup, en Python, on va écrire une classe “InputLayer” qui aura une petite spécialisation sur la méthode “process”, comme suit :

class InputLayer(Layer):
    def process(self, data_set:Sequece[float]):
        """Each neuron of the first layer get a single input."""
        assert len(data_set) == len(self._neurons)
        for n, d in dict(zip(self._neurons, data_set)).items():
            n.process([d])

Du coup, notre constructeur de réseau, il faut lui expliquer l’histoire de la première couche (ouais il n’est pas très malin, il a besoin qu’on lui explique les trucs) :

class Network:
    """A simple fully connected network."""
    def __init__(self, layers_conf:Sequence[], learning_rate:int):
        """Create the network."""
        assert len(layers_conf) > 0
        self._lr = learning_rate
        # Here we create our first layer which is an input layer
        self._layers = [InputLayer(**layers_conf[0])]
        # Then we add any additional layer as a classical Layer.
        for layer_conf in layers_conf[1:]:
            self._layers.append(Layer(**layer_conf))

D’ailleurs, les experts, quand ils parlent d’un réseau de neurone, il ne comptent jamais la première couche. Donc s’ils disent « un réseau de trois couches », ça veut dire « un réseau avec une première couche qui n’intéresse personne, deux couches cachées, et une couche de sortie » (y’a que la couche d’entrée, qui est discriminé, celle de sortie, ça va).

Bref, notre réseau aussi, doit avoir ses méthodes “process” et “update” :

class Network:
    # ...
    def process(self, data_set:Sequence[float]):
        """Treat the given sequence of float numbers."""
        for l in self._layers:
            # Each layer treats the data, which means give the data to its neurons
            l.process(data_set)
            # Then we get the result to forward it to the next layer.
            data_set = [n.result for n in l.neurons]

    def update(self, expected:Sequence[float]):
        """Now, we can update the network according to the expected sequence."""
        for l in self._layers:
            expected = l.update(expected, self._lr)  # wut?!

Oups, je crois que j’avais oublié de parler d’un petit truc …

Je ne sais pas si vous vous en souvenez, mais tout à l’heure, on disait que notre neurone apprenait ses leçons grâce à une « fonction objectif ». Vous vous souvenez, celle que les anglais appellent “loss function” ?

Alors avec un seul neurone, ou une couche de neurone, ce n’est pas la mer à boire : on a directement accès à leurs entrées et sorties. Mais quand on a un réseau avec plein de couches, il faut qu’on trouve un moyen de propager la sortie qu’on attend de chaque neurone, couche par couche …

C’est pour ça que, dans la méthode Network::updateplus haut, on attend d’e chaque couche qu’elle nous renvoi ce qu’elle attend de la couche qui la précède : pour pouvoir y transmettre !

Donc pour que notre couche renvoi ses attentes, on met à jour la méthode Layer::update comme suit :

class Layer:
    # ...
    def update(self, expected:Sequence[float], learning_rate:float):
        """Update each neuron of the layer."""
        assert len(expected) == len(self._neurons)
        hoped = []
        for n, e in dict(zip(self._neurons, expected)).items():
            hoped.append(n.update(e, learning_rate))  # I've a bad feeling, about that...
        return hoped
    # ...

Et comme vous vous en douteriez (surtout si vous avez la référence à Star Wars), on doit aussi mettre à jour la méthode Neuron::update pour que chaque neurone nous fasse part de ses attentes :

class Neuron:
    # ...
    def update(self, expected:float, learning_rate:float):
        """The learning process.

            The expected value and learning rate are given on the update step.
            This neuron returns what it expects itself to let the input neuron use it.
            More on it later ;)
        """
        loss = expected - self._result
        self._w = self._w + (loss * learning_rate)
        self._b = self._b + (loss * learning_rate)
        # Now, we return what the neuron expected as output...
        return (self._b - expected) / self._w
    # ...

Bon, y’a un petit truc de maths qui se cache ici, par contre … Désolé, j’avais oublié de vous le mentionner (mea culpa). Si vous vous rappelez, j’ai utilisé une fonction mathématique toute simple (comparé à ce qu’on est censé avoir) pour traiter les données : y = ax + b. Ou alors, avec les bons termes : résultat = (pondération * donnée) + biais.

Du coup là, on est dans la situation où on connait le résultat, mais qu’on veut retrouver la donnée … Vous savez, c’est comme chercher la question quand on sait que la réponse c’est 42 (mais promis, on ne fera pas exploser l’univers, nous). Et c’est pour ça qu’on retourne notre petite fonction de tout à l’heure, comme suit : donnée = (biais - résultat) / pondération.

Et du coup, en Machine Learning, c’est ce qu’on appelle … la rétro-propagation !

Mais oui ! Encore un truc dont j’ai parlé tout à l’heure, là, y’a … quoi ? Une ½ heure ?

Et voilà ! On en a fini avec les maths !!! \o/

Du coup, je vous proposes un autre petit schéma pour résumer le tout …

Et ce script, là, c’est quoi tout ces trucs qu’on y retrouve ?

Dans le script que je vous ai partagé au tout début, il y a quelques petits outils en plus.

  • Tout en haut du script, j’ai rajouté deux petites fonction normalize et denormalize qui ramène les valeurs entre 0 et 1. Parce que visblement, le réseau, il préfère les valeurs entre 0 et 1 (comme moi je préfère une bonne côte de bœuf … mais je m’égare).
  • J’ai voulu faire en sorte que ma classe Neuron puisse être surchargée (les méthodes avec des _ au début). Comme ça, s’il y a des gens plus matheux que moi, ils peuvent implémenter des vraies fonctions mathématiques de réseau de neurone et voir ce que ça fait.
  • J’ai rajouté des méthodes __repr__ et __str__ que les pythonistes connaissent : ça permet d’avoir une belle mise en forme quand on test des objets Pythons dans le terminal.
  • Il y a une class LayerConfiguration assez grosse. En fait, j’ai voulu rendre la configuration un peu plus facile à manipuler, en surchargeant les opérateurs + et * (bon ok, je me suis aussi un peu amusé).
  • Enfin, j’ai rajouté trois fonctions train_neuron, train_layer et train_network pour rendre les tests (respectivement de neurones, de couches et de réseau) un peu plus lisibles.

Je me suis amusé à exécuter quelques tests de réseau simple (ce qu’il y a dans la partie if __name__ == '__main__'). En fait, j’ai été vachement impressionné par la précision des résultats, juste avec ce « ridicule » petit modèle. Je vous conseille d’éssayer, puis de bidouiller le script, pour le voir par vous-même.

Et oui, parce que comme mentionné au début, on a pris plein de raccourcis :

  1. déjà, on a testé 3 couches avec 3 neurones chacun (plus le mal aimé “input layer”), alors que les réseaux d’experts comptent des millions de neurones ;
  2. ensuite, ils utilisent des fonctions mathématiques carrément plus performantes qu’une simple fonction affine (mon fameux y = ax + b) ;
  3. les vrais réseaux tournent sur des GPU, avec du code optimisé spécialement pour ça ;
  4. enfin, ces mêmes vrais réseaux utilisent plein d’autres concepts encore plus complexes, et des combinaisons de concepts plus complexes (comme ça, c’est encore plus complexe, avec encore plusd de maths !).

Enfin bref, j’espère que cet article (assez long, faut le dire) vous a apporté des choses dans la compréhension des réseaux de neurone.


L’illustration de fonction affine et l’image de couverture viennent de Wikipédia.

Toutes les autres illustrations sont de mon fait, avec un petit coup de pouce de Google Drawing.