Publié par
Il y a 2 mois · 16 minutes · Data

TensorFlow & Deep Learning – Episode 3 – Modifiez votre Réseau de Neurones en toute simplicité

tensorflow

Dans le précédent article, nous avons mis en place toute la mécanique de création et d’entraînement d’un réseau de neurones. De la gestion des inputs jusqu’à la visualisation des résultats dans TensorBoard, en passant par les opérations d’inférence et d’entraînement, toute la chaîne était présente pour entraîner notre premier réseau de neurones: le Softmax Regression.

Au long de cet article, nous n’allons pas nous arrêter en si bon chemin et voir que la modification du réseau de neurones n’a que très peu d’impact sur la structure globale du code et que la majorité des étapes implémentées restent les mêmes. Nous pourrons donc très simplement implémenter et améliorer de manière itérative notre réseau de neurones, se plaçant ainsi dans un fonctionnement agile et permettant de ne pas tout recommencer.

La structure de notre projet ne change pas

Pour rappel, le code source des exemples présentés dans les différents articles se trouve sur le repository Github associé.

La structure de notre projet est la suivante :

  • Gestion des inputs (extraction et création des placeholders)
  • Opérations pour l’inférence (création du réseau de neurones à proprement parler)
  • Opération pour la fonction de coût (cross-entropie)
  • Opération pour l’entraînement du modèle (Stochastic Gradient Descent)
  • Opération pour l’évaluation du modèle (accuracy)
  • Gestion des summaries dans TensorBoard
  • Entraînement du réseau de neurones

La bonne nouvelle pour nous est que, quelle que soit la structure du réseau de neurones, rien ne change dans l’enchaînement des étapes et dans la manière dont elles sont construites. La seule étape qui va subir des modifications est l’étape d’inférence, dans laquelle nous allons spécifier les opérations spécifiques à la création du réseau de neurones. Mais quelle que soit la structure du réseau, les inputs et outputs seront toujours les mêmes, permettant ainsi de garder le code autour inchangé.

Ajout d’une couche cachée

Le réseau de neurones

Nous allons dans un premier temps complexifier la structure de notre réseau de neurones en ajoutant une couche cachée (Hidden Layer) :

Dans l’article précédent, nous avions vu que le passage entre l’entrée et la sortie d’un Softmax Regression n’était autre qu’un produit de matrices auquel on ajoute un vecteur de biais au sein d’une fonction softmax. Ajouter une couche intermédiaire est en fait très simple dans la mesure où il suffit d’ajouter un nouveau produit matriciel avec une nouvelle matrice de poids. Nous aurons donc un produit matriciel correspondant au passage de l’input à la couche cachée, puis un autre pour le passage de la couche cachée à l’output. Si l’on considère le schéma précédent, l’opération est la suivante (K étant le nombre d’images dans le batch d’entrée) :

Produit matriciel

Comme on peut le constater, une étape intermédiaire entre les deux produits matriciels consiste à appliquer une fonction que l’on appelle fonction d’activation. C’est cette fonction, qui est en général non linéaire, qui permet au réseau de neurones d’apprendre des fonctions plus complexes. Une fonction couramment utilisée est le relu (Rectified Linear Unit) : f(x) = max(0, x).

Modification de la phase d’inférence

Comme nous l’avons vu, il nous suffit de modifier la phase d’inférence pour changer la structure du réseau de neurones. Nous allons nous créer deux fonctions spécifiques : une permettant l’ajout d’une couche intermédiaire et une autre gérant la dernière opération de softmax (on retourne aussi les logits – l’output du réseau juste avant le softmax – pour la fonction de coût). Voici les implémentations :

def add_dense_hidden_layer_op(x, num_neurons_previous_layer, num_neurons_current_layer, name_scope):
    with tf.name_scope(name_scope):
  # Model parameters
        weights = tf.Variable(tf.truncated_normal(shape=[num_neurons_previous_layer, num_neurons_current_layer], stddev=0.1, name="weights"))
        biases = tf.Variable(tf.constant(0.1, shape=[num_neurons_current_layer], name="biases"))
        
  # Activation function
  relu = tf.nn.relu(tf.matmul(x, weights) + biases, name=name_scope)

  # TensorBoard Summary
        tf.summary.histogram(relu.op.name + '/activations', relu)
        
  return relu
 
def add_softmax_op(x, num_neurons_previous_layer, num_classes, name_scope="softmax"):
    with tf.name_scope(name_scope):
        # Model parameters
        weights = tf.Variable(tf.zeros([num_neurons_previous_layer, num_classes]))
        biases = tf.Variable(tf.zeros([num_classes]))

  # Logits and softmax layer
        logits = tf.matmul(x, weights) + biases
        softmax = tf.nn.softmax(logits)

  # TensorBoard Summary
        tf.summary.histogram(softmax.op.name + '/activations', x)

        return softmax, logits

La fonction d’ajout d’une couche intermédiaire effectue donc les actions suivantes :

  • Création des variables de poids et de biais
  • Produit matriciel entre la couche précédente et la matrice de poids, puis ajout des biais
  • Application de la fonction d’activation (relu)
  • Création du résumé pour TensorBoard

La fonction pour le softmax effectue les mêmes actions, en appliquant la fonction softmax à la place du relu.

Il ne reste plus qu’à enchaîner les différentes étapes pour créer notre nouveau réseau de neurones. Attention à bien faire en sorte que le Tensor d’entrée d’une étape soit le Tensor de sortie de l’étape précédente, avec le bon nombre de neurones.

dense = add_dense_hidden_layer_op(x=x, num_neurons_previous_layer=num_pixels, num_neurons_current_layer=1024, name_scope="dense")
softmax = add_softmax_op(x=dense, num_neurons_previous_layer=1024, num_classes=num_classes, name_scope="softmax")

Et voilà ! Tout le reste peut rester inchangé (fonction de côut, évaluation, entraînement, etc.), et il n’y a plus qu’à relancer l’entraînement du modèle. Il est donc très simple de tester plusieurs structures de réseaux de neurones.

Ajout d’autres couches intermédiaires

Créer plusieurs couches intermédiaires revient alors simplement à enchaîner les appels à nos fonctions de création de couche en faisant attention aux entrées et sorties. Par exemple, pour ajouter trois couches intermédiaires (500, 100 et 50 neurones respectivement), le code à écrire est le suivant dans la phase d’inférence, sans toucher au reste :

dense_1 = add_dense_hidden_layer_op(x=x, num_neurons_previous_layer=num_pixels, num_neurons_current_layer=500, name_scope="dense_1")
dense_2 = add_dense_hidden_layer_op(x=dense_1, num_neurons_previous_layer=500, num_neurons_current_layer=100, name_scope="dense_2")
dense_3 = add_dense_hidden_layer_op(x=dense_2, num_neurons_previous_layer=100, num_neurons_current_layer=50, name_scope="dense_3")

softmax = add_softmax_op(x=dense_3, num_neurons_previous_layer=50, num_classes=num_classes, name_scope="softmax")

Convolutional Neural Network

Si on observe les résultats, on peut se rendre compte que l’ajout de couches intermédiaires supplémentaires ne permet pas toujours d’améliorer les résultats, et c’est parfois même le contraire. Pourtant, l’ajout de ces couches permet en théorie d’apprendre des fonctions plus complexes, et donc de réussir à mieux classifier les images. En pratique, plus on ajoute de couche dense, plus il y a de paramètres à estimer (le nombre de paramètres explose très rapidement). De plus, plus il y a de couches intermédiaires, plus la mise à jour des poids reliant ces dernières par back-propagation devient difficile car les vitesses de mises à jour sont différentes entre les couches finales et les premières couches.

L’intuition

En prenant un petit peu de recul, on peut se demander si l’utilisation d’une telle structure de réseau de neurones est la meilleure idée qui soit. En effet, chaque neurone intermédiaire prend en considération les valeurs de tous les neurones de la couche précédente. Est-ce une bonne idée de croiser directement la valeur du pixel du coin en haut à droite de l’image avec celui du coin en bas à gauche ? Pas forcément, car nous ne prenons pas en considération la structure spatiale de l’image (cf image ci-dessous).

structure spatiale de l'image

C’est ce problème que cherchent à résoudre les Convolutional Neural Networks. Cette architecture prend en compte la structure spatiale spécifique aux images en appliquant des patchs plus petits qui sont déplacés en long et en large de l’image. Ces patchs vont jouer le rôle de filtres chargés de détecter des formes particulières, de plus en plus complexes d’une couche à l’autre.

architecture structure spatiale

Les réseaux de neurones par convolutions sont basés sur trois hypothèses principales:

  • Associations locales : tous les pixels ne sont pas connectés à tous les neurones cachés. Les connexions sont faites dans des petites zones localisées de l’image.
  • Mêmes poids et biais pour tous les neurones d’une même couche : un seul type de filtre est appliqué dans une même couche, afin de détecter un pattern particulier (un coin, un angle, etc.). Tous les neurones d’une même couche détectent la présence ou non du même pattern.
  • Pooling : condensation de l’information. On ne cherche pas à connaître l’emplacement exact d’un pattern, sa localisation approximative suffit. Il est donc courant de faire suivre une couche de convolution par une phase de pooling qui va condenser l’information et réduire la dimension des phases intermédiaires (on va par exemple garder la valeur maximale de 4 neurones d’une même zone).

Plusieurs couches de convolution peuvent alors s’enchaîner, et la dernière couche est suivie d’une couche dense où tous les neurones sont liés entre eux, pour enfin appliquer une fonction softmax.

Modification de l’inférence

La différence avec les structures de réseaux de neurones que nous avons implémentées jusque là est qu’il ne faut plus éclater l’image d’entrée en un vecteur de pixels. Les couches de convolution prennent en entrée les images telles quelles. Hormis ce détail, seule l’étape d’inférence est à modifier, en laissant tout le reste intact. Peu importe la structure du réseau de neurone, il n’y a que l’étape d’inférence qui est impactée.

Nous allons donc nous créer deux nouvelles fonctions : une pour l’ajout d’une opération de convolution (via tf.nn.conv2d) et l’autre pour l’opération de max-pooling (via tf.nn.max_pool).

def add_conv_op(x, num_pixels_conv, num_channels_in_previous_layer, num_channels_in_current_layer, name_scope):
    with tf.name_scope(name_scope):
  # Model parameters
        weights = tf.Variable(tf.truncated_normal(shape=[num_pixels_conv, num_pixels_conv, num_channels_in_previous_layer, num_channels_in_current_layer],
                                                  stddev=0.1, name="weights"))
        biases = tf.Variable(tf.constant(0.1, shape=[num_channels_in_current_layer], name="biases"))

  # Activation function
        relu = tf.nn.relu(tf.nn.conv2d(x, weights, strides=[1, 1, 1, 1], padding="SAME") + biases, name=name_scope)

  # TensorBoard Summary
        tf.summary.histogram(relu.op.name + '/activations', relu)

        return relu
 
def add_max_pool_op(x, name_scope):
    return tf.nn.max_pool(x, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding="SAME", name=name_scope)

Dans la fonction d’ajout de la couche de convolution, plusieurs arguments sont importants à stipuler :

  • x : le Tensor d’entrée
  • num_pixels_conv : le nombre de pixels que contient le filtre à appliquer. Cela correspond donc à la taille du filtre, une valeur de 5 équivaut à dire que l’on applique un patch de 5×5 sur l’image
  • num_channels_in_previous_layer : le nombre de de channels présents dans la couche précédente (par exemple, pour une image en RGB, le nombre de channels est de 3)
  • num_channels_in_current_layer : le nombre de de channels que l’on souhaite créer (par exemple, passer d’un Tensor à 3 channels à un Tensor à 6 channels) afin d’apprendre plus de représentations latentes des données
  • name_scope : le nom de l’opération, que l’on retrouvera dans TensorBoard

En utilisant ces deux nouvelles fonctions, on peut créer un réseau de neurones à convolutions. Voici l’implémentation pour deux convolutions et une couche dense :

# First convolution layer
conv_1 = add_conv_op(x=x, num_pixels_conv=5, num_channels_in_previous_layer=1, num_channels_in_current_layer=16, name_scope="conv_1")
pool_1 = add_max_pool_op(x=conv_1, name_scope="pool")

# Second convolution layer
conv_2 = add_conv_op(x=pool_1, num_pixels_conv=5, num_channels_in_previous_layer=16, num_channels_in_current_layer=32, name_scope="conv_2")
pool_2 = self.add_max_pool_op(x=conv_2, name_scope="pool")

# Reshape the output from the second convolution for the fully connected layer
shape = pool_2.get_shape().as_list()
pool_reshaped = tf.reshape(pool_2, shape=[-1, shape[1] * shape[2] * shape[3]])

# Fully connected layer
dense = add_dense_hidden_layer_op(x=pool_reshaped, 
             num_neurons_previous_layer=shape[1]*shape[2]*shape[3],
                                  num_neurons_current_layer=64,
                                  name_scope="dense")

# Final softmax layer
softmax = add_softmax_op(x=dense, num_neurons_previous_layer=64, num_classes=num_classes, name_scope="softmax")

Une petite explication des valeurs choisies pour l’architecture présentée ici s’impose.architecture convolution

L’objectif est d’être capable, d’une couche à l’autre, de réduire l’information pour aller à l’essentiel de ce que contient l’image. Mais même si l’information est réduite, il faut toujours être capable de capter les différentes formes qui composent une image et surtout être capable de les distinguer d’une classe à l’autre.

C’est pourquoi il n’est pas rare, d’une couche de convolution à l’autre, d’augmenter le nombre de channels (donc de filtres appliqués à l’image) et en même temps de réduire la taille de l' »image » filtrée par la suite. Dans notre exemple, la première couche de convolution nous permet de passer d’une entrée à un channel (le niveau de gris de l’image d’origine) à une sortie à 16 channels (16 représentations intermédiaires de l’image suite à l’application de filtres différents).

La taille de l’image de sortie reste la même, et c’est l’opération de max pooling qui nous permet de condenser l’information (en divisant la taille de l’image par deux dans notre cas). Le même principe est appliqué pour la seconde couche de convolution : on passe d’une entrée à 16 channels à une sortie à 32 channels, puis on divise la taille de l’image par deux grâce à du max pooling. Ainsi, nous sommes capables de condenser l’information (en réduisant la taille des « images » intermédiaires) tout en ayant un mapping de plus en plus complet des différentes formes à distinguer dans l’image (en augmentant le nombre de channels).

Une fois les étapes de convolutions terminées, les neurones restants sont éclatés en un seul vecteur pour repasser à une couche dense, jusqu’à appliquer le softmax. Le schéma fourni (provenant de l’introduction au Deep Learning faite par Martin Görner) aide à avoir une vision globale de ce qu’il se passe.

Une fois cette architecture comprise, il est ensuite très simple d’encapsuler d’autres couches de convolution et d’autres couches denses pour améliorer les résultats du modèle.

Pour aller plus loin

Les exemples fournis ici vous permettent d’atteindre jusqu’à 93% de bonnes prédiction, ce qui est bien mais perfectible. Voici quelques modifications à tester pour améliorer les résultats :

  • Dropout : entre chaque couche dense, il est commun d’utiliser du dropout. C’est une technique de régularisation (pour combattre l’overfitting) dont le principe est de désactiver aléatoirement à chaque itération un certain pourcentage des neurones d’une couche. Cela évite ainsi la sur-spécialisation d’un neurone (et donc l’apprentissage par coeur). Voir pour cela tf.nn.dropout().
  • Learning rate decay : le learning rate est quelque chose de très important et impactant dans les performances d’un réseau de neurones. Une valeur trop grande peut aider à converger rapidement au début, mais être problématique dans les dernières étapes, pouvant jusqu’à causer une divergence. Il est souvent intéressant de faire décroître le learning rate en fonction de l’avancement des itérations. Voir pour cela tf.train.exponential_decay().
  • Fonction d’activation : nous avons jusque là utilisé une descente de gradient stochastique comme optimiseur pour l’apprentissage. Certains optimiseurs peuvent s’avérer être bien plus efficaces : AdamOptimizer, AdagradOptimizer, etc.
  • Tester d’autres valeurs pour les tailles des couches cachées

Comment lancer le code ?

Dans le code proposé sur le repository Github, plusieurs architectures sont proposées. Dans le dossier train, vous pouvez lancer l’entraînement du réseau de neurones sélectionné directement depuis votre ligne de commande ou bien via votre IDE. En sélectionnant par exemple le réseau de neurones avec deux couches de convolution, il vous suffit de lancer la commande suivante :

python tensorflow_image_tutorial/train/train_two_conv.py

Chaque module dans le dossier train permet de lancer l’entraînement d’une architecture différente de réseau de neurones. Il font chacun appel à un module différent dans le dossier operation/inference. Tous les autres modules dans operation sont utilisés par toutes les architectures. C’est tout l’avantage de n’avoir qu’à modifier l’architecture du réseau, sans avoir besoin de se soucier de modifier quoi que ce soit d’autre.

La puissance d’exécution, la documentation, le potentiel : des arguments de choix

TensorFlow est un outil très puissant pour réaliser des tâches de Deep Learning (mais ce n’est pas son unique utilité). Sa puissance d’exécution, sa documentation très riche et ses outils visuels l’ont très rapidement fait devenir l’un des frameworks actuels les plus importants pour le Deep Learning.

Bien qu’il y ait un certain gap pour s’approprier toutes les notions relatives à TensorFlow, tout est à notre disposition pour construire et entraîner efficacement des réseaux de neurones complexes pour un grand nombre d’applications. Au cours de ces trois articles, nous avons vu pas à pas la construction d’une mécanique pour la création et l’entraînement d’un réseau de neurones. L’avantage principal est qu’il est possible de modifier la structure du réseau de neurones (phase d’inférence) sans avoir à impacter les autres étapes.

TensorFlow oblige l’utilisateur à spécifier une grande partie de ce qui compose le réseau de neurones. Cela permet de s’assurer que l’on a bien compris son fonctionnement ainsi que d’avoir la main sur beaucoup de paramètres. Depuis la version 1.0, une API de plus haut niveau est disponible pour construire des réseaux de neurones plus rapidement, avec les modules tf.layers, tf.losses et tf.metrics. On peut aussi citer la librairie Keras, qui est une librairie pour une implémentation plus haut niveau des réseaux de neurones, avec le choix d’un moteur d’exécution en TensorFlow ou Theano. Cela permet d’écrire les programmes en moins de lignes de code, mais avec légèrement moins de flexibilité sur chaque étape implémentée.

TensorFlow n’a pas fini d’évoluer, et va rapidement devenir un must-have du Deep Learning. Peu d’autres librairies fournissent autant de possibilités au développeur, avec autant d’exemples, tutoriels et outils de contrôle. C’est ce qui a permis à TensorFlow de devenir un standard en un temps record.

Yoann Benoit
Yoann est Data Scientist chez Xebia depuis près de deux ans. Il intervient sur de nombreux sujets autour de la Data Science et du Big Data, allant de la collecte, du traitement et de l'analyse des données jusqu'à la mise en production de pipelines complets de Machine Learning. Speaker et rédacteur à la fois sur les concepts et les technologies liées à la Data Science, il travaille principalement avec Python, Scala et Spark. Il intervient de plus en tant que formateur sur l'Analyse de Données et le Machine Learning sur Spark.

Laisser un commentaire

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