Publié par
Il y a 8 mois · 18 minutes · Data

TensorFlow & Deep Learning – Episode 2 – Notre premier réseau de neurones

tensorflowMaintenant que nous avons vu les bases de TensorFlow, nous allons pouvoir commencer à entrer dans le vif du sujet et implémenter notre premier réseau de neurones. L’objectif de cet article est de décortiquer les grandes étapes nécessaires à la création et à l’entraînement d’un réseau de neurones, jusqu’à la visualisation finale des résultats dans TensorBoard. Le réseau de neurones que nous allons développer ici est le plus simple qui soit : le Softmax Regression.

Ces étapes sont essentielles à tout projet de Deep Learning. Nous verrons par la suite à quel point il sera simple de modifier l’architecture du réseau de neurones sans toucher aux autres étapes, permettant ainsi de tester de nombreuses architectures simplement.

Vous pourrez trouver le code fourni dans cet article sur le projet Github associé. Passons maintenant à l’action !

Le cas d’usage traité

Les données

Afin d’illustrer les propos de cet article avec un exemple concret, nous allons travailler sur une application de classification d’images. Le dataset utilisé est NotMnist, une version similaire au célèbre dataset MNIST, appliqué à de la reconnaissance de lettres. Notre objectif est d’être capable de reconnaître et de distinguer 10 lettres distinctes : de A à J. Voici à quoi ressemblent les images à disposition pour la lettre A :

nomnist_example

Nous aurons à notre disposition 200 000 images pour l’entraînement, ainsi que 10 000 pour la validation et 10 000 pour le test final. La taille des images est fixe : 28×28 pixels.

NB: Ce dataset est utilisé dans le MOOC d’udacity  sur TensorFlow

Softmax Regression

Le réseau de neurones que nous allons implémenter dans un premier temps est le Softmax Regression. L’architecture de ce réseau est la plus simple qui soit :

image_reshaped_softmax

Tous les pixels de l’image (4 dans le schéma ci-dessus et 1 biais, 784  dans la réalité) sont éclatés en un seul vecteur, et la valeur du neurone d’entrée correspond à l’intensité du pixel associé. Sont ensuite définies autant de sorties qu’il y a de classes (2 dans le schéma, 10 dans notre cas d’application)  et chaque neurone d’entrée est relié à chaque neurone de sortie via un poids (qui n’est autre qu’un coefficient multiplicateur).

Lorsqu’une image est envoyée dans le réseau, elle est donc d’abord éclatée en un vecteur, puis les valeurs des pixels sont multipliés par leurs poids associés pour donner les valeurs des neurones de sortie. Un vecteur de constantes, appelés biais est ensuite ajouté. Le biais est un degré de liberté supplémentaire à notre modèle, et joue le même rôle que l’intercept dans une régression linéaire. Une dernière étape, appelée softmax, consiste à normaliser les valeurs des sorties afin qu’elles correspondent à des probabilités (entre 0 et 1) et que leur somme soit de 1. La classe prédite correspondra alors au neurone de sortie indiquant la plus grande probabilité. Tout le travail pour entraîner ce réseau correspond donc à trouver les meilleurs poids et biais qui permettent de passer des neurones d’entrée à ceux de sortie.

En réalité, le calcul associé pour traverser le réseau n’est autre qu’un produit matriciel du vecteur d’entrée avec une matrice de poids pour donner un vecteur de sortie. Mieux encore, le principe appliqué est le même lorsqu’il s’agit d’un batch d’images (un ensemble d’images). Un batch de 100 images correspond alors à une matrice de taille [100, 4] sur le schéma, et la sortie obtenue après application des poids et des biais est une matrice de taille [100, 2].

Softmax Regression2

Quelles sont les grandes étapes à implémenter ?

Pour tout projet de Deep Learning, quelle que soit l’architecture choisie, certaines étapes sont obligatoires. On peut les résumer en trois grandes catégories : gestion des données d’entrée, construction et gestion du réseau de neurones, et évaluation du modèle.

Gestion des inputs

Nos inputs correspondent dans notre cas aux images présentes dans le dataset d’entraînement. Nous n’entrerons pas dans le détail de l’optimisation de l’apprentissage d’un réseau de neurones, mais il faut savoir que le fait d’utiliser toutes les images du dataset à chaque itération n’est pas la meilleure idée qui soit. Il est plutôt recommandé de travailler par batch d’images (un batch de 100 images, par exemple) représentatif des différentes catégories à classifier. Cette manière de faire permet un apprentissage, une optimisation plus souple et une convergence plus rapide.

Au-delà de la gestion des batches d’entrée, il est aussi fréquent d’effectuer une phase de pre-processing des images avant de les envoyer dans le réseau de neurones. Cette phase peut inclure une grande variété de traitements: resizing, random cropping, flipping and rotating,   etc. Les avantages de ces phases sont nombreux, notamment pour la normalisation des images (un réseau de neurones n’accepte généralement qu’un seul type d’image et de taille), ainsi que pour l’augmentation artificielle de la taille du dataset (en appliquant des transformations aléatoires, le réseau de neurones est confronté à de nouvelles images).

Construction et gestion du réseau de neurones

On distingue 3 phases distinctes et essentielles pour la création et l’optimisation de la convergence du modèle :

  • Inférence : Cette étape correspond à toutes les étapes nécessaires pour effectuer les prédictions des classes à partir des inputs (les batches d’images). Cela correspond à la définition de l’architecture du réseau de neurones en lui-même.
  • Fonction de coût : Afin de faire en sorte que le réseau de neurones puisse faire des prédictions correctes, il faut définir une fonction de coût qu’il faudra chercher à optimiser à chaque itération jusqu’à convergence. On utilise généralement la cross-entropie comme fonction de coût.
  • Entraînement : Une fois définies l’architecture du réseau ainsi que la fonction de coût à optimiser, il faut passer à l’étape d’optimisation à proprement parler. C’est dans cette phase que nous allons définir de quelle manière les poids du réseau vont être mis à jour à chaque itération. Généralement, ce sont des techniques de backpropagation qui sont utilisées, avec des algorithmes de type Stochastic Gradient Descent.

C’est au cours de cette étape que le réseau de neurones créé va vraiment « apprendre » à distinguer les classes. Il va en effet « visionner » à chaque étape de nouvelles images, et mettre à jour les poids en fonction des erreurs de prédiction qui sont faites.

Evaluation du modèle

La cross-entropie est une bonne fonction de coût pour l’entraînement des réseaux de neurones, mais ce n’est pas la mesure la plus parlante lorsqu’il s’agit de définir si le modèle a de bonnes performances. C’est pourquoi il n’est pas rare de se rajouter une dernière Operation correspondant à un calcul de précision sur un dataset de validation (un jeu de données sur lequel le réseau ne s’est pas entraîné) afin de mesurer la capacité de généralisation du réseau et d’éviter l’overfitting, ou apprentissage par coeur.

L’évaluation du modèle ne se fait pas forcément à chaque étape de la phase d’entraînement, mais plutôt à intervalles réguliers afin de voir la progression générale des performances du modèle.

Réalisation des différentes étapes

Passons maintenant à l’implémentation des différentes phases que nous avons décrites jusque là.

Inputs

Comme expliqué dans l’article précédent, la gestion des inputs se fait grâce à des placeholders, qui permettent de définir la structure des Tensors sans avoir à spécifier de valeur fixe. Le contenu du Tensor est ajouté et modifié à chaque étape de l’entraînement du modèle via le paramètre feed_dict de la méthode run().

Voici le code associé à la création des placeholders nécessaires dans notre cas:

with tf.name_scope("input"):
    x = tf.placeholder(tf.float32, shape=(None, num_pixels))
    y = tf.placeholder(tf.float32, shape=(None, num_classes))

Comme on peut le voir, la première dimension du Tensor est fixée à None, ce qui nous permet de changer la taille des batchs comme bon nous semble.

Les données associées à la validation et au test pourront être traitées telles quelles. Seules des données d’entraînement doivent être séparées par batch et fournies en entrée des placeholders.

Inférence

Comme définie précédemment, la phase d’inférence nous permet d’appliquer les transformations nécessaires pour passer d’un input (un batch d’images) à une prédiction (pour chaque image, un vecteur de probabilités). Voici le code associé pour une Softmax Regression:

with tf.name_scope("softmax"):
    # Model parameters
    weights = tf.Variable(tf.zeros([num_pixels, num_classes]))
    biases = tf.Variable(tf.zeros([num_classes]))

    logits = tf.matmul(x, weights) + biases
    softmax = tf.nn.softmax(logits)

Tout le code pour le calcul du softmax est implémenté dans un bloc spécifique, ce qui nous permettra de nous y retrouver dans TensorBoard. Dans un premier temps, il faut instancier les poids et les biais. Comme expliqué dans l’article précédent, le mieux est d’utiliser une Variable. Ils sont ici initialisés avec des zéros, mais nous les initialiserons dans les prochains réseaux avec de faibles valeurs aléatoires afin d’éviter que les différents neurones apprennent la même chose.

Une fois toutes les variables instanciées, on passe au produit matriciel entre le batch d’images et les poids, puis on ajoute les biais. On peut enfin utiliser la fonction softmax sur le résultat pour obtenir les probabilités.

Afin de pouvoir visualiser par la suite la distribution des valeurs en sortie du softmax dans TensorBoard, il faut rajouter le summary associé dans le code:

# Softmax values histogram
tf.summary.histogram(softmax.op.name + '/activations', softmax)

Fonction de coût

Comme dit précédemment, nous allons utiliser la cross-entropie comme fonction de coût. C’est cette métrique qui nous permettra de mesurer à quel point les prédictions (probabilités) sont éloignées de la vérité, et c’est à partir de celle-ci que les poids et biais seront mis à jour dans l’étape suivante. Voici le code associé à la création de la fonction de coût:

with tf.name_scope("cross_entropy"):
    labels = tf.cast(labels, tf.int64)
    cross_entropy = tf.nn.sparse_softmax_cross_entropy_with_logits(logits, labels)
    cross_entropy_mean = tf.reduce_mean(cross_entropy, name="cross_entropy")

    tf.summary.scalar("cross_entropy", cross_entropy_mean)

La cross-entropie pour chaque image est calculée via la méthode sparse_softmax_cross_entropy_with_logits. L’entropie moyenne sur le batch est alors calculée via la méthode reduce_mean. Il convient ensuite de définir le summary associé pour suivre l’évolution de la cross-entropie moyenne en fonction de l’itération en cours.

Entraînement

Afin de boucler la boucle pour la construction et la gestion du réseau de neurones, il reste maintenant à définir la phase d’entraînement, c’est à dire la manière dont les poids et les biais vont être mis à jour en fonction de la cross-entropie calculée précédemment ainsi que d’un learning rate permettant d’impacter de manière plus ou moins forte cette mise à jour. Un learning rate élevé va mener à des variations des valeurs des poids plus élevés si l’erreur est importante. Tout l’enjeu est de choisir une valeur suffisamment importante pour que la convergence se fasse rapidement, mais suffisamment faible pour ne pas diverger.

Comme expliqué précédemment, nous allons utiliser une descente de gradient stochastique pour gérer la mise à jour des poids et biais. Voici le code associé à l’entraînement du réseau de neurones:

 with tf.name_scope("train"):
    # Optimizer
    optimizer = tf.train.GradientDescentOptimizer(learning_rate)

    # Use the optimizer to apply the gradients that minimize the loss (and also increment the global step
    # counter) as a single training step.
    train_op = optimizer.minimize(loss, global_step=global_step)

La descente de gradient stochastique est gérée via la classe GradientDescentOptimizer, instancié avec le learning_rate. L’optimiseur créé possède alors une méthode minimize à laquelle on fournit la loss de l’étape précédente. La classe créée utilise aussi un global_step qui permet de tracer l’itération en cours.

Evaluation

Cette dernière étape permet d’avoir un critère plus compréhensible pour observer la convergence du modèle et de s’assurer que l’on ne fait pas d’overfitting. Elle consiste en un simple calcul d’accuracy entre les classes prédites et réelles. Voici le code associé à la création de cette Operation:

with tf.name_scope("accuracy"):
    correct_prediction = tf.equal(tf.argmax(softmax, 1), tf.argmax(labels, 1))
    accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))
 
    tf.summary.scalar("accuracy", accuracy)

Classes associées

Dans le code fourni, chacune de ces étapes sont séparées dans des classes associées. Cela permet un découpage plus propre du code, et offre ainsi la possibilité par exemple de modifier le code d’une des étapes indépendamment des autres.

Voici un exemple pour la phase d’inférence:

class InferenceOp:
    def __init__(self, num_pixels, num_classes, name_scope="softmax"):
        self.num_pixels = num_pixels
        self.num_classes = num_classes
        self.name_scope = name_scope

    def add_ops(self, x):
        """
        Build the softmax op to make final predictions (probabilities)

        :param x: Input Tensor
        :return: softmax: Output Tensor with the computed logits.
        """

        with tf.name_scope(self.name_scope):
            # Model parameters
            weights = tf.Variable(tf.zeros([self.num_pixels, self.num_classes]))
            biases = tf.Variable(tf.zeros([self.num_classes]))

            logits = tf.matmul(x, weights) + biases
            softmax = tf.nn.softmax(logits)

        # Softmax values histogram
        tf.summary.histogram(softmax.op.name + '/activations', softmax)

        return softmax, logits

Ainsi, si je souhaite modifier l’architecture du réseau de neurones, il suffit d’apporter les modifications dans la classe InferenceOp, sans avoir à se soucier des étapes de loss et d’entraînement, ce qui sera très pratique par la suite.

Exécution du code

Il ne nous reste maintenant plus qu’à joindre les morceaux créés précédemment. Passons dès maintenant au code de l’entraînement du réseau de neurones, que nous commenterons ensuite.

NB: Pour exécuter le code, il faut créer les différentes variables qui y sont nommées (présentes dans le code sur Github). Le but ici est d’expliciter chaque étape.

def main():
    """
    Train NotMNIST for a number of steps.
    """
    # Load data
    train_dataset, train_labels, valid_dataset, valid_labels, test_dataset, test_labels = DataLoader(
        data_dir=data_dir,
        image_size=28,
        num_labels=10).load()

    with tf.Graph().as_default():
        global_step = tf.Variable(0, name='global_step', trainable=False)

        # Start running operations on the Graph.
        sess = tf.Session()

        """
        Step 1 - Input data management
        """

        # Input data
        x, y = InputOp(num_pixels=IMAGE_SIZE*IMAGE_SIZE, num_classes=10).add_op()

        # Reshape images for visualization
        x_reshaped = tf.reshape(x, [-1, IMAGE_SIZE, IMAGE_SIZE, 1])
        tf.summary.image('input', x_reshaped, NUM_CLASSES)

        """
        Step 2 - Building the graph
        """

        # Build a Graph that computes the logits predictions from the inference model.
        softmax, logits = InferenceOp(num_pixels=NUM_PIXELS, num_classes=NUM_CLASSES).add_ops(x)

        # Calculate loss.
        loss = LossOp(name_scope="cross_entropy").add_op(logits, y)

        # Build a Graph that trains the model with one batch of examples and updates the model parameters.
        train_op = TrainingOp(learning_rate=learning_rate, name_scope="train").add_op(loss, global_step)

        """
        Step 3 - Build the evaluation step
        """

        # Model Evaluation
        accuracy = EvaluationOp(name_scope="accuracy").add_op(softmax, y)

        """
        Step 4 - Merge all summaries for TensorBoard generation
        """
        # Create a saver.
        saver = tf.train.Saver(tf.global_variables())

        # Build the summary operation based on the TF collection of Summaries.
        summary_op = tf.summary.merge_all()

        # Summary Writers
        train_summary_writer = tf.summary.FileWriter(summaries_dir + '/train', sess.graph)
        validation_summary_writer = tf.summary.FileWriter(summaries_dir + '/validation', sess.graph)

        """
        Step 5 - Train the model, and write summaries
        """

        # Build an initialization operation to run below.
        init = tf.global_variables_initializer()
        sess.run(init)

        for step in xrange(max_steps):

            start_time = time.time()

            # Pick an offset within the training data, which has been randomized.
            offset = (step * batch_size) % (train_labels.shape[0] - batch_size)

            # Generate a minibatch.
            batch_data = train_dataset[offset:(offset + batch_size), :]
            batch_labels = train_labels[offset:(offset + batch_size), :]

            # Run training step and train summaries
            summary_train, _, loss_value = sess.run([summary_op, train_op, loss],
                                                    feed_dict={x: batch_data, y: batch_labels})

            train_summary_writer.add_summary(summary_train, step)

            duration = time.time() - start_time

            if step % 10 == 0:
                num_examples_per_step = batch_size
                examples_per_sec = num_examples_per_step / duration
                sec_per_batch = float(duration)

                # Run summaries and measure accuracy on validation set
                summary_valid, acc_valid = sess.run([summary_op, accuracy],
                                                    feed_dict={x: valid_dataset, y: valid_labels})

                validation_summary_writer.add_summary(summary_valid, step)

                format_str = '%s: step %d, accuracy = %.2f (%.1f examples/sec; %.3f sec/batch)'
                print (format_str % (datetime.now(), step, 100 * acc_valid, examples_per_sec, sec_per_batch))

        acc_test = sess.run(accuracy, feed_dict={x: test_dataset, y: test_labels})
        print ('Accuracy on test set: %.2f' % (100 * acc_test))

Les grandes étapes d’exécution suivent l’ordre logique détaillé initialement :

  1. Gestion des données d’entrée
    1. Chargement des datasets
    2. Création des placeholders
  2. Construction du graphe d’exécution
    1. Inférence
    2. Cross-entropie
    3. Entraînement
  3. Gestion de l’évaluation du modèle (calcul de l’accuracy)
  4. Gestion des résumés présents dans TensorBoard
  5. Entraînement du modèle et écriture des résumés. Pour chaque itération :
    1. Création du batch de données d’entraînement
    2. run du résumé et de l’entraînement en fournissant le dictionnaire contenant les données d’entrée
    3. Ecriture des résumés
    4. Récupération de l’accuracy sur le validation set et écriture du résumé associé toutes les 10 itérations
  6. Estimation des performances sur le test set

Voici les commandes à lancer pour exécuter le code à partir du projet fourni (il faut avoir un environnement Python avec TensorFlow installé):

cd ~/tensorflow_image_tutorial
python tensorflow_image_tutorial/train/train_softmax.py

Cette dernière commande va lancer le main détaillé précédemment. Comme vous pourrez le voir, les étapes de création des opérations se situent dans le sous-package operation, et le main se situe dans le sous-package train.

NB: Vous pourrez constater qu’il y a plusieurs fonctions d’entraînement dans le sous-package train, ainsi que plusieurs modules d’inférence dans le sous-package operation/inference. Ceux-ci correspondent aux évolutions que nous proposerons dans le prochain article. Dans cet article, nous avons implémenté les modules spécifiques pour le softmax.

Visualisation des résultats dans TensorBoard

En se fixant 5000 itérations, une taille de batch de 128 images et un learning_rate de 0.1, on converge vers un score de 83% de bonnes prédictions sur le validation set, et on obtient un score légèrement supérieur à 89% sur le test set.

Mais le mieux reste encore d’observer directement la convergence du modèle et les résultats sur TensorBoard. Il suffit de lancer la commande suivante pour afficher le board sur votre navigateur:

tensorboard --logdir="/tmp/not_mnist/not_mnist_logs"

Voici quelques observations disponibles :

resultats-TensorBoard

TensorBoard méritant un article détaillé à lui tout seul, nous allons expliquer ces images de manière très simple.

  • En haut à gauche : L’évolution de l’accuracy en fonction des itérations. Elle augmente très rapidement dans les premières itérations (correspondant à des grosses variations des poids pour se spécialiser) pour petit à petit se stabiliser et converger vers 90%. Il est important de constater qu’il n’y ait pas de décrochage entre la courbe de train et de validation, ce qui signifie qu’il n’y a pas d’overfitting.
  • En haut à droite : Le graphe qui a été créé pour l’exécution. On y retrouve toutes les étapes que nous avons implémentées.
  • En bas à gauche: On rentre plus dans le détail du réseau de neurones créé en observant la distribution de l’activation de la couche de softmax. Chaque courbe plus colorée représente un quartile.
  • En bas à droite : Les histogrammes des valeurs des neurones de sortie. Il faut s’assurer que l’histogramme du softmax tende bien vers une distribution bimodale, avec la majorité des valeurs proches de 0 et une sous-partie proche de 1. Cela permet de s’assurer que le modèle est « sûr de lui », tant pour les prédictions négatives que positives.

Conclusion

Félicitations ! Nous avons construit toutes les étapes nécessaires à la création et l’entraînement d’un réseau de neurones simple (Softmax Regression). Bien qu’il soit indéniable qu’il y a un certain gap à franchir pour bien assimiler les différentes notions autour du fonctionnement de TensorFlow, nous avons pu constater que pour chaque étape principale que l’on souhaite implémenter, il y a à notre disposition un ou plusieurs opérations associées à cette tâche.

Une fois toute cette mécanique implémentée, tout devient plus simple. Nous verrons dans le prochain article à quel point il sera aisé de modifier l’architecture du réseau de neurones pour améliorer les résultats en ne faisant qu’un minimum de modifications dans le code. En effet, hormis pour l’inférence, toutes les autres étapes implémentées restent identiques.

See you on the next episode.

Yoann Benoit
Yoann est Data Scientist chez Xebia. Il est également formateur au sein de Xebia Training .

2 réflexions au sujet de « TensorFlow & Deep Learning – Episode 2 – Notre premier réseau de neurones »

  1. Publié par Kisaku, Il y a 8 mois

    tutorial bien expliqué mais inutilisable pour un néophyte car il manque plein d’éléments.
    exemple : dans le tutorial
    – labels n’est pas défini au moment où il est référencé dans la fonction coût
    – learning_rate non défini
    – idem pour num_pixels et num_classes non initialisés
    etc…
    après la transition entre les étapes précédentes et la définiton des classes associées n’est pas claire.
    ……
    dommage donc pour ce code qui ne permet une exécution …

  2. Publié par Yoann Benoit, Il y a 8 mois

    Bonjour Kisaku,

    Merci pour votre commentaire. En effet le code tel quel dans l’article n’a pas forcément vocation à être utilisable en l’état. L’idée est de présenter les concepts à travers les différentes lignes de code qui constituent le socle de votre application. Les constantes (learning_rate, num_pixels, etc.) ne sont donc pas renseignées car sont spécifiques à votre application, hors l’article cherche à être générique.

    Pour pouvoir exécuter correctement le code pour ce cas d’application, vous pouvez cloner le repository github pour avoir accès au projet complet, avec toutes les constantes de bien renseignées.

    J’espère que cela répond à vos questions.

Laisser un commentaire

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