Un réseau de neurones from scratch

Introduction

La popularisation du Deep Learning par des librairies de haut niveau comme TensorFlow ou Keras permet aujourd'hui de coder un réseau de neurones en manipulant des abstractions de haut niveau.
Par exemple, en Keras, créer un modèle RNN (Reccurent Neural Network) ne prend que quelques lignes en ajoutant des couches d'après des classes précablées :

model = Sequential()
model.add(Embedding(vocsize, s['emb_dimension']))
model.add(SimpleRNN(s['nhidden'], activation='sigmoid', return_sequences=True))
model.add(TimeDistributed(Dense(output_dim=nclasses)))
model.add(Activation("softmax"))
model.compile(loss='categorical_crossentropy', metrics=['accuracy'])

Inutile de savoir ce qu'il y a dans la tuyauterie de chaque couche pour pouvoir faire de l'assemblage et regarder le réseau apprendre (tout se joue plutôt dans les valeurs des paramètres qu'on passe à chaque couche).

Ceci étant, mettre un peu les mains dans le cambouis et aller voir de plus près comment les choses fonctionnent permet de désacraliser un peu les mathématiques qui se cachent derrière.

Cet article reprend un tutorial de iamtrask qui propose de coder de A à Z et en quelques lignes de code un réseau de neurones implémentant un mécanisme de "back propagation". L'idée est de démystifier la théorie en la couchant sur un IDE et l'abordant avec des mots de profane. Les premiers paragraphes ont justement pour but d'y voir plus clair dans la forêt de mots du domaine : perceptron, neurone, backpropagation, ...

Disclaimer

Je ne suis ni un mathématicien, ni un datascientist. J'aborde les principes du Deep Learning de mon point de vue : celui d'un informaticien qui s'intéresse aux techniques d'apprentissage automatique par l'axe de la pratique.

Réseau de neurones

La première question c'est bien sur : qu'est-ce que c'est qu'un réseau de neurones et à quoi ça sert ? J'imagine qu'il y a beaucoup de façon de répondre à ça, et certaines réponses sont sans doute bien plus justes et précises que d'autres. Une façon d'aborder le sujet est de faire le parallèle avec le fonctionnement du cerveau : c'est par ce biais qu'on a commencé à évoquer ces principes dans le milieu des années 50.

Neurones et perceptrons

L'idée de neurone vient de l'analogie avec le cortex cérébral. Un neurone est constitué en entrée et en sortie de dendrites et d'un axiome, qui sont chacun des filaments par lesquels transitent des signaux électriques. Le neurone reçoit ainsi des impulsions de différentes intensités et choisit, selon un certain seuil, d'émettre ou pas un signal en sortie.

Capture-d-e-cran-2017-11-14-a--10.17.06-1

On parle de perceptron qui est l'équivalent mathématique du neurone biologique. Un perceptron reçoit des entrées, fait un calcul mathématique et renvoie une sortie.

Autopsie du perceptron

En entrée, le perceptron prend une série de valeurs Xi, représentées chacune par un nœud. Chaque nœud d'entrée est associé à un poids Wij. Le perceptron effectue la somme pondérée de toutes les valeurs Xi : cette opération est appelée fonction de combinaison.

Capture-d-e-cran-2017-11-14-a--10.20.23-2

En pratique, on homogénéise les sorties des perceptrons pour se ramener à des notions de probabilité. Pour cette raison, on utilise une fonction d'activation qui se charge de remettre la valeur sommée entre 0 et 1. La fonction Sigmoïd est un exemple de fonction qui remplit classiquement ce rôle.

sigmoid

Dans ce fonctionnement, le poids associé à une valeur d'entrée simule finalement l'intensité du signal électrique reçu par un neurone biologique.

On y reviendra par la suite, mais on peut d'ores et déjà noter que tout l'enjeu du DeepLearning réside dans le fait de trouver les poids qui donnent le "bon" résultat de sortie.

Des perceptrons par centaines

Cependant, tout seul, un perceptron ne peut pas faire grand chose. Toujours par analogie avec le cortex cérebral, on associe les perceptrons par couches : chaque sortie du perceptron d'une couche donnée est ainsi reliée à l'entrée des perceptrons de la couche suivante.
Ainsi un RNN, ou Recurent Neuronal Network, est un réseau dans lequel des couches de perceptrons sont associées les unes aux autres.

Dans un réseau de neurones, les couches sont normalisées :

  • Dans la première couche, chaque nœud correspond à une donnée d'entrée.
  • Les couches intermédiaires correspondent aux calculs proprement dits. Dans la forme la plus courante du réseau de neurones, chaque nœud d'une couche possède un certain nombre de paramètres d'entrée, et renvoie son résultat de sortie à tous les noeuds de la couche suivante. Ces couches sont appelées "couches cachées".
  • La dernière couche a pour rôle de produire la sortie finale du réseau de neurones.

Il est important de noter que les nœuds d'une même couche ne communiquent pas entre eux.

Quand on arrive à ce niveau de compréhension, on se pose naturellement la question de savoir comment on détermine le nombre de couches cachées. Celui-ci dépend du problème à résoudre, et c'est un travail de spécialiste du domaine que de trouver, le plus souvent par expérimentation, le type et le nombre de ces couches.
Actuellement, le réseau le plus profond contient 156 couches cachées (!)

Du nombre de dimensions (ou autrement dit, du nombre de paramètres que chaque nœud accepte) des couches cachées dépend la généricité du modèle. En apprenant sur plus de paramètres, le réseau est capable de plus d'abstraction.

Yann Lecun, une éminence française en matière d'intelligence artificielle décrit ce fonctionnement dans ces termes (source : Collège de France) :

L’idée est très simple : le système entraînable est constitué d’une série de modules, chacun représentant une étape de traitement. Chaque module est entraînable, comportant des paramètres ajustables similaires aux poids des classifieurs linéaires. Le système est entraîné de bout en bout : à chaque exemple, tous les paramètres de tous les modules sont ajustés de manière à rapprocher la sortie produite par le système de la sortie désirée. Le qualificatif profond vient de l’arrangement de ces modules en couches successives.

Pour pouvoir entraîner le système de cette manière, il faut savoir dans quelle direction et de combien ajuster chaque paramètre de chaque module. Pour cela il faut calculer un gradient, c’est-à-dire pour chaque paramètre ajustable, la quantité par laquelle l’erreur en sortie augmentera ou diminuera lorsqu’on modifiera le paramètre d’une quantité donnée. Le calcul de ce gradient se fait par la méthode de rétropropagation, pratiquée depuis le milieu des années 80.

Dans sa réalisation la plus commune, une architecture profonde peut être vue comme un réseau multicouche d’éléments simples, similaires aux classifieurs linéaires, interconnectés par des poids entraînables. C’est ce qu’on appelle un réseau neuronal multicouche.

Apprentissage, modèle et prédiction

Une fois qu'on a dit tout ça, on comprend mieux ce qu'est formellement un neurone et comment il est capable d'échanger des informations avec des copains pour former un réseau, et on voit bien pourquoi on parle de "Deep" pour évoquer un réseau qui a beaucoup de couches. Mais ça ne nous dit pas vraiment pour autant pourquoi il y a "Learning" dans "DeepLearning", ni pourquoi on nous bassine avec des histoires de "modèles" et de "prédiction" (non parce que si on parle d'astrologie, je préfère le savoir tout de suite hein ?)

De ce point de vue, machine learning et deep learning fonctionnent sur des principes identiques. Tout projet reposant sur ces techniques requiert les étapes suivantes :

  1. Trouver et formaliser la question à laquelle on veut répondre
  2. Identifier les signaux qui vont rentrer en jeu pour répondre à la question
  3. Recueillir, agréger, nettoyer, formater les données pour produire les signaux
  4. Construire un modèle de prédiction
  5. Evaluer le modèle de prédiction
  6. Retourner en 2. et recommencer jusqu'à obtenir un résultat satisfaisant :)

Construire un modèle de prédiction consiste à trouver les "bons" poids pour chaque nœud de chaque couche cachée. C'est cette phase qui est appelée "apprentissage".
Dans le cas le plus courant (apprentissage supervisé), on soumet en entrée du réseau des données pour lesquelles on connait la valeur de sortie désirée. En fonction du résultat constaté et de celui attendu, les poids sont automatiquement ajustés par un mécanisme dit de "backpropagation". Plus on soumet d'exemples, plus les poids convergent vers une valeur qui permet d'obtenir la sortie attendue.
Les poids de chaque couche (stockés sous forme de matrice) forment ce qu'on appelle le modèle du réseau.

Une fois le modèle obtenu, on peut passer de nouvelles données d'entrée au réseau pour qu'il "calcule" la valeur de sortie : c'est la phase de prédiction.

Les pieds de la marionnette

Le problème

Pour bien fixer les idées et comprendre dans les détails le fonctionnement, on peut créer un réseau de A à Z en quelques lignes de code. C'est exactement ce qu'on va faire maintenant.

Imaginons un problème simpliste dans lequel à partir de trois valeurs d'entrée, on essaye de trouver une valeur de sortie. Admettons que nous connaissions déjà les valeurs attendues pour quatre vecteurs d'entrée différents. Ces données vont constituer notre corpus d'apprentissage :

X1 X2 X3 y
0 0 1 1
0 1 1 1
1 0 1 0
1 1 1 0

On utilise ici de bêtes chiffres, mais ils peuvent représenter un problème réel : par exemple, notre réseau pourrait répondre à la question : "est-ce qu'un consommateur va acheter une bouteille de vin donnée ?". Dans ce cas, X1, X2 et X3 pourraient représenter des caractéristiques du vin (bourgogne ou bordeaux ? blanc ou rouge ? bio ou pas ?) ou des données de contexte (il fait beau ou mauvais ?) ou encore des choses qu'on sait sur le consommateur (plus ou moins de 40 ans ?). "Y" quant à lui correspondrait pour chaque triplet d'entrée à la probabilité de voir le consommateur acheter la bouteille.

Nous allons construire pas à pas un réseau à une seule couche et utiliser ces données pour créer un modèle qui résolve ce problème dans tous les cas.

Le code (python inside) !

Commençons par importer la librairie numpy. Elle permet de faire facilement du calcul matriciel qui sera nécessaire plus loin.

import numpy as np

Nous avons ensuite besoin de deux fonctions qu'il nous faut définir. La première est la fonction d'activation "sigmoid". Comme vu plus haut, elle permet de remettre une valeur entre les bornes 0 et 1.

def sigmoid(x):
    '''
    Fonction d'activation. Met la valeur d'entrée entre 0 et 1.
    '''
    return 1/(1+np.exp(-x))

Comme nous le verrons plus loin, nous avons également besoin de sa dérivée : pour rappel, la dérivée permet de calculer la pente d'une fonction en un point donné. C'est elle que nous utiliserons pour savoir si les poids des matrices doivent être augmenté ou diminué.

def pente_sigmoid(x):
    '''
    Dérivée (pente) de la sigmoid
    '''
    return x*(1-x)

Mettons ensuite les données supervisées dans une matrice en utilisant le type "array" fourni par numpy :

# Entrée
X = np.array([
    [1, 0, 0],
    [1, 1, 0],
    [0, 0, 1],
    [0, 1, 1]
 ])

# Résultats attendus
y = np.array([
    [1, 1, 0, 0]
]).T

Notez que le ".T" à la fin de l'expression n'a pour autre but que de "tourner" la matrice et de faire en sorte que chaque ligne de la matrice d'entrée soit "en face" de sa donnée de sortie (pour ceux qui ont les meilleurs souvenirs du Lycée, c'est une transposée).

Ensuite, on initialise les poids des neurones du réseau. Comme il faut bien démarrer avec quelque chose, le plus simple est de faire ça de manière aléatoire, l'apprentissage se chargera de faire converger les poids vers leur véritable valeur.

# Initialisation des poids
 syn0 = np.random.random((3,1))

Pour faire l'apprentissage, on va boucler un certain nombre de fois sur un algorithme qui va, à chaque itération, modifier les valeurs des poids pour tenter de s'approcher du résultat attendu.

# Apprentissage
for i in range(1000):
    # l0 est la couche d'entrée. Elle contient les données de la matrice d'entrée X.
    l0 = X

    # l1 est la valeur de sortie : pour la calculer, on fait la somme pondérée de toutes ls valeurs d'entrée.
    # Exprimée en matrice, cela revient à faire le produit scalaire de l0 et de syn0.
    # On remet enfin cette somme entre 0 et 1, grace à la fonction sigmoid.
    l1 = sigmoid(np.dot(l0, syn0))

    # On doit ensuite calculer l'erreur. Cela va nous permettre d'ajuster correctement les poids (faut-il les réduire ?
    # les augmenter ?).
    # Cette erreur, c'est le résultat attendu (y) moins le résultat obtenu (l1)
    erreur = y - l1

    # Pour optimiser la façon dont on adapte les poids, on "regarde" où on se situe sur la pente de la sigmoid : est-ce
    # qu'on est sur une portion plutôt verticale ? Dans ce cas il faut beaucoup modifier les poids. Est-on au contraire
    # sur une portion plate ? Dans ce cas, le résultat est plus sur et il faut peu modifier les poids.
    delta = pente_sigmoid(l1) * erreur

    # Une fois le delta trouvé, on modifie les poids : on leur ajoute à chacun le résultat du produit scalaire de delta
    # par la donnée d'entrée.
    syn0 += np.dot(l0.T, delta)

Les résultats

A l'issue de cet apprentissage, on peut afficher le résultat calculé (l1) par le modèle syn0 (l1) pour chaque triplet d'entrée (X). Idéalement,les valeurs prédites devraient être proches de celles attendues (y), soit pour chaque triplet :

X1 X2 X3 y
0 0 1 1
0 1 1 1
1 0 1 0
1 1 1 0

Au bout de 10 itérations, l1 ressemble à ça :

[[ 0.77492364]
 [ 0.79531142]
 [ 0.2121011 ]
 [ 0.23301088]]

Soit :

X1 X2 X3 y l1
0 0 1 1 0.77492364
0 1 1 1 0.79531142
1 0 1 0 0.2121011
1 1 1 0 0.23301088

Cela s'interprète de la façon suivante :

  • Pour le triplet 0,0,1 le réseau pense à 77% que le consommateur va acheter la bouteille
  • Pour le triplet 0,1,1 le réseau pense à 79% que le consommateur va acheter la bouteille
  • Pour le triplet 1,0,1 le réseau pense à 21% que le consommateur va acheter la bouteille
  • Pour le triplet 1,1,1 le réseau pense à 23% que le consommateur va acheter la bouteille

La confiance du réseau dans ses résultats est modérée.

Au bout de 100 itérations la confiance est bien meilleure :

[[ 0.9419285 ]
 [ 0.94537674]
 [ 0.05334291]
 [ 0.05671517]]

Soit :

X1 X2 X3 y l1
0 0 1 1 0.9419285
0 1 1 1 0.94537674
1 0 1 0 0.05334291
1 1 1 0 0.05671517

Qui s'interprète de la façon suivante :

  • Pour le triplet 0,0,1 le réseau pense à 94% que le consommateur va acheter la bouteille
  • Pour le triplet 0,1,1 le réseau pense à 94% que le consommateur va acheter la bouteille
  • Pour le triplet 1,0,1 le réseau pense à 5% que le consommateur va acheter la bouteille
  • Pour le triplet 1,1,1 le réseau pense à 5% que le consommateur va acheter la bouteille

Conclusion

Ces quelques lignes de code permettent de réaliser qu'un réseau neuronal ne tient finalement qu'en quelques fonctions mathématiques dont le rôle est assez simple (même si elles peuvent être très complexes en pratiques), et dans du calcul matriciel (ce qui explique pourquoi les cartes GPU amènent un tel gain dans le traitement de ces algorithmes).