TensorFlow, Keras et deep learning, sans doctorat

1. Présentation

Ce tutoriel a été mis à jour pour TensorFlow 2.2.

74f6fbd758bf19e6.png

Dans cet atelier de programmation, vous allez apprendre à créer et entraîner un réseau de neurones capable de reconnaître des chiffres manuscrits. Au fur et à mesure que vous améliorez votre réseau de neurones pour atteindre une précision de 99 %, vous découvrirez également les outils que les professionnels du deep learning utilisent pour entraîner efficacement leurs modèles.

Cet atelier de programmation utilise l'ensemble de données MNIST, une collection de 60 000 chiffres étiquetés qui a occupé plusieurs générations de doctorats pendant près de 20 ans. Vous allez résoudre le problème avec moins de 100 lignes de code Python / TensorFlow.

Points abordés

  • Qu'est-ce qu'un réseau de neurones et comment l'entraîner ?
  • Créer un réseau de neurones basique à une couche à l'aide de tf.keras
  • Ajouter des calques
  • Configurer une programmation pour le taux d'apprentissage
  • Créer des réseaux de neurones convolutifs
  • Utiliser les techniques de régularisation: abandon, normalisation des lots
  • Qu'est-ce que le surapprentissage ?

Prérequis

Un simple navigateur. Cet atelier peut être entièrement mené avec Google Colaboratory.

Commentaires

Veuillez nous indiquer si vous constatez une anomalie dans cet atelier ou si vous pensez qu'elle doit être améliorée. Nous traitons les commentaires via GitHub [lien de commentaires].

2. Guide de démarrage rapide de Google Colaboratory

Cet atelier utilise Google Colaboratory et ne nécessite aucune configuration de votre part. Vous pouvez l'exécuter depuis un Chromebook. Veuillez ouvrir le fichier ci-dessous et exécuter les cellules pour vous familiariser avec les notebooks Colab.

c3df49e90e5a654f.png Welcome to Colab.ipynb

Vous trouverez des instructions supplémentaires ci-dessous:

Sélectionner un backend GPU

hsy7H7O5qJNvKcRnHRiZoyh0IznlzmrO60wR1B6pqtfdc8Ie7gLsXC0f670zsPzGsNy3QAJuZefYv9CwTHmjiMyywG2pTpnMCE6Slkf3K1BeVmfpsYVw6omItm1ZneqdE31F8re-dA

Dans le menu Colab, sélectionnez Environnement d'exécution > Modifiez le type d'environnement d'exécution, puis sélectionnez "GPU". La connexion à l'environnement d'exécution se fera automatiquement lors de la première exécution. Vous pouvez également utiliser la commande dans le coin supérieur droit.

Exécution du notebook

evlBKSO15ImjocdEcsIo8unzEe6oDGYnKFe8CoHS_7QiP3sDbrs2jB6lbyitEtE7Gt_1UsCdU5dJA-_2IgBWh9ofYf4yVDE740PwJ6kiQwuXNOLkgktzzf0E_k5VN5mq29ZXI5wb7Q

Pour exécuter les cellules une par une, cliquez dessus et utilisez Maj + ENTRÉE. Vous pouvez également exécuter l'intégralité du notebook avec Environnement d'exécution > Tout exécuter

Sommaire

OXeYYbtKdLCNnw_xovSMeMwSdD7CL_w25EfhnpRhhhO44bYp3zZpU72J5tKaSuo8wpas0GK5B2sTBlIMiFmdGxFRQ9NmwJ7JIRYy5XtpWKQCPdxQVRPy_0J_LshGIKjtw8P9fXozaA

Tous les notebooks comportent une table des matières. Vous pouvez l'ouvrir à l'aide de la flèche noire située à gauche.

Cellules masquées

GXTbXUO8xpPFKiGc6Q-cFwFHxHvOa105hHg3vk77EDpStyhU4AQMN3FYenbiBusHXUSk-yGXbRDcK-Cwx18XbDtyqB5WRr3_2jhnLvFxW8a7H_4cGvVDKrEMto_QxhfTeO0hwmrfng

Certaines cellules n'affichent que leur titre. Cette fonctionnalité de notebook spécifique à Colab. Vous pouvez double-cliquer dessus pour voir le code à l'intérieur, mais ce n'est généralement pas très intéressant. Elles sont généralement compatibles avec les fonctions de visualisation ou de compatibilité. Vous devez quand même exécuter ces cellules pour que les fonctions qu'elles contiennent soient définies.

3. Entraîner un réseau de neurones

Nous allons d'abord regarder l'entraînement d'un réseau de neurones. Veuillez ouvrir le notebook ci-dessous et parcourir toutes les cellules. Ne faites pas encore attention au code, nous commencerons à l'expliquer plus tard.

c3df49e90e5a654f.png keras_01_mnist.ipynb

Pendant que vous exécutez le notebook, concentrez-vous sur les visualisations. Vous trouverez des explications ci-dessous.

Données d'entraînement

Nous disposons d'un ensemble de données composé de chiffres manuscrits, qui ont été étiquetés afin de savoir ce que chaque image représente, c'est-à-dire un nombre compris entre 0 et 9. Dans le notebook, vous verrez un extrait:

ad83f98e56054737.png

Le réseau de neurones que nous allons créer classe les chiffres manuscrits dans leurs 10 classes (0, ..., 9). Pour ce faire, les paramètres internes doivent être associés à une valeur correcte pour que la classification fonctionne correctement. Cette « valeur correcte » est appris grâce à un processus d'entraînement qui nécessite un "ensemble de données étiqueté" avec des images et les bonnes réponses associées.

Comment savoir si le réseau de neurones entraîné est performant ou non ? Utiliser l'ensemble de données d'entraînement pour tester le réseau serait une tricherie. Cet ensemble de données a déjà été vu plusieurs fois pendant l'entraînement, et il est très certainement très performant dessus. Nous avons besoin d'un autre ensemble de données étiqueté, jamais vu lors de l'entraînement, pour évaluer le "monde réel" les performances du réseau. Il s'agit d'un ensemble de données de validation.

Entraînement

À mesure que l'entraînement progresse, un lot de données d'entraînement à la fois, les paramètres internes du modèle sont mis à jour et la reconnaissance des chiffres manuscrits s'améliore avec le modèle. Vous pouvez le voir sur le graphe d'entraînement:

3f7b405649301ea.png

À droite, le paramètre "accuracy" est simplement le pourcentage de chiffres correctement reconnus. Il augmente au fur et à mesure de l'entraînement, ce qui est bien.

À gauche, nous pouvons voir la "loss". Pour piloter l'entraînement, nous allons définir une "perte" qui représente la mauvaise reconnaissance des chiffres par le système. Ce que vous voyez ici, c'est que la perte diminue à mesure que l'entraînement progresse sur les données d'entraînement et de validation, ce qui est bien. Cela signifie que le réseau de neurones apprend.

L'axe des abscisses représente le nombre d'époques ou des itérations sur l’ensemble de données complet.

Prédictions

Une fois le modèle entraîné, nous pouvons l'utiliser pour reconnaître des chiffres manuscrits. La visualisation suivante montre ses performances sur quelques chiffres affichés à partir de polices locales (première ligne), puis sur les 10 000 chiffres de l'ensemble de données de validation. La classe prédite apparaît sous chaque chiffre, en rouge si elle est incorrecte.

c0699216ba0effdb.png

Comme vous pouvez le voir, ce modèle initial n'est pas très bon, mais reconnaît tout de même certains chiffres correctement. Sa justesse de validation finale est d'environ 90 %, ce qui n'est pas si mauvais pour le modèle simpliste avec lequel nous commençons,mais cela signifie tout de même qu'il manque 1 000 chiffres de validation sur les 10 000. Vous pouvez afficher beaucoup plus d'informations, ce qui explique pourquoi il semble que toutes les réponses sont fausses (en rouge).

Tensors

Les données sont stockées dans des matrices. Une image en nuances de gris de 28 x 28 pixels s'intègre dans une matrice bidimensionnelle de 28 x 28. Mais pour une image en couleur, nous avons besoin de plus de dimensions. Il existe trois valeurs de couleur par pixel (rouge, vert, bleu). Vous aurez donc besoin d'un tableau en trois dimensions avec des dimensions [28, 28, 3]. Et pour stocker un lot de 128 images en couleur, un tableau à quatre dimensions avec des dimensions [128, 28, 28, 3] est nécessaire.

Ces tables multidimensionnelles sont appelées "Tensors", et la liste de leurs dimensions correspond à leur "forme".

4. [INFO]: Introduction aux réseaux de neurones

En résumé

Si vous connaissez déjà tous les termes en gras dans le paragraphe suivant, vous pouvez passer à l'exercice suivant. Si vous débutez dans le deep learning, bienvenue et poursuivez votre lecture.

witch.png

Pour les modèles créés sous la forme d'une séquence de couches, Keras propose l'API Sequential. Par exemple, un classificateur d'images utilisant trois couches denses peut être écrit dans Keras sous la forme suivante:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=[28, 28, 1]),
    tf.keras.layers.Dense(200, activation="relu"),
    tf.keras.layers.Dense(60, activation="relu"),
    tf.keras.layers.Dense(10, activation='softmax') # classifying into 10 classes
])

# this configures the training of the model. Keras calls it "compiling" the model.
model.compile(
  optimizer='adam',
  loss= 'categorical_crossentropy',
  metrics=['accuracy']) # % of correct answers

# train the model
model.fit(dataset, ... )

688858c21e3beff2.png

Une seule couche dense

Les chiffres manuscrits de l'ensemble de données MNIST sont des images en niveaux de gris de 28 x 28 pixels. L'approche la plus simple pour les classer consiste à utiliser les pixels 28 x 28=784 comme entrées pour un réseau de neurones à une couche.

Capture d'écran 26/07/2016 - 12.32.24.png

Dans un réseau de neurones, chaque "neurone" calcule la somme pondérée de toutes ses entrées et ajoute une constante appelée "biais" puis transmet le résultat à l'aide d'une "fonction d'activation" non linéaire. Les pondérations et les biais sont des paramètres qui seront déterminés lors de l'entraînement. Elles sont d'abord initialisées avec des valeurs aléatoires.

L'image ci-dessus représente un réseau de neurones à une couche avec 10 neurones de sortie, car nous voulons classer les chiffres en 10 classes (de 0 à 9).

Avec une multiplication matricielle

Voici comment la couche d'un réseau de neurones, qui traite un ensemble d'images, peut être représentée par une multiplication matricielle:

matmul.gif

En utilisant la première colonne de pondérations de la matrice des pondérations W, nous calculons la somme pondérée de tous les pixels de la première image. Cette somme correspond au premier neurone. En utilisant la deuxième colonne de pondérations, nous faisons de même pour le deuxième neurone, et ainsi de suite jusqu'au 10e neurone. Nous pouvons ensuite répéter l'opération pour les 99 images restantes. Si nous appelons X la matrice contenant nos 100 images, toutes les sommes pondérées pour nos 10 neurones, calculées sur 100 images, sont simplement X-W, une multiplication matricielle.

Chaque neurone doit maintenant ajouter son biais (une constante). Comme nous avons 10 neurones, nous avons 10 constantes de biais. Nous appellerons ce vecteur de 10 valeurs "b". Elle doit être ajoutée à chaque ligne de la matrice précédemment calculée. Utiliser une part de magie appelée "diffusion" nous allons l'écrire avec un simple signe plus.

Enfin, nous appliquons une fonction d'activation, par exemple "softmax" (expliqué ci-dessous) et obtenez la formule décrivant un réseau de neurones à une couche, appliquée à 100 images:

Capture d'écran 26/07/2016 - 16.02.36.png

Dans Keras

Avec les bibliothèques de réseaux de neurones de haut niveau telles que Keras, il n'est pas nécessaire d'implémenter cette formule. Cependant, il est important de comprendre qu'une couche de réseau de neurones est constituée d'un ensemble de multiplications et d'additions. Dans Keras, une couche dense serait écrite comme suit:

tf.keras.layers.Dense(10, activation='softmax')

Approfondir vos connaissances

Il est facile d'enchaîner les couches d'un réseau de neurones. La première couche calcule la somme pondérée des pixels. Les couches suivantes calculent les sommes pondérées des sorties des couches précédentes.

fba0638cc213a29.png

La seule différence, hormis le nombre de neurones, sera le choix de la fonction d'activation.

Fonctions d'activation: relu, softmax et sigmoïde

On utilise généralement le « relu » fonction d'activation pour toutes les couches, à l'exception de la dernière. La dernière couche, dans un classificateur, utiliserait "softmax" l'activation.

644f4213a4ee70e5.png

Là encore, un "neurone" calcule la somme pondérée de toutes ses entrées, ajoute une valeur appelée "biais" et transmet le résultat via la fonction d'activation.

La fonction d'activation la plus populaire est appelée RELU pour l'unité de rectification linéaire. Comme vous pouvez le voir sur le graphique ci-dessus, il s'agit d'une fonction très simple.

Dans les réseaux de neurones, la fonction d'activation traditionnelle était le "sigmoïde", mais le "relu". présente de meilleures propriétés de convergence presque partout et est désormais à privilégier.

41fc82288c4aff5d.png

Activation de softmax pour la classification

La dernière couche de notre réseau de neurones comporte 10 neurones, car nous voulons classer les chiffres manuscrits en 10 classes (0 à 9). Il doit générer 10 chiffres compris entre 0 et 1, représentant la probabilité que ce chiffre soit un 0, un 1, un 2, etc. Pour ce faire, sur la dernière couche, nous allons utiliser une fonction d'activation appelée softmax.

L'application de la fonction softmax à un vecteur s'effectue en prenant l'exposant exponentiel de chaque élément, puis en normalisant le vecteur, généralement en le divisant par sa "L1" (c'est-à-dire la somme des valeurs absolues), de sorte que la somme des valeurs normalisées soit égale à 1 et puisse être interprétée comme une probabilité.

La sortie de la dernière couche, avant l'activation, est parfois appelée "logits". Si ce vecteur est L = [L0, L1, L2, L3, L4, L5, L6, L7, L8, L9], alors:

ef0d98c0952c262d.png d51252f75894479e.gif

Perte d'entropie croisée

Maintenant que notre réseau de neurones produit des prédictions à partir d'images d'entrée, nous devons mesurer leur qualité, c'est-à-dire la distance entre ce que le réseau nous dit et les bonnes réponses, souvent appelées "étiquettes". N'oubliez pas que nous disposons d'étiquettes correctes pour toutes les images de l'ensemble de données.

N'importe quelle distance conviendrait, mais pour les problèmes de classification, la "distance d'entropie croisée" est la plus efficace. Nous appellerons cela notre erreur ou « perte » :

6dbba1bce3cadc36.png

Descente de gradient

« Entraînement » le réseau de neurones consiste en fait à utiliser des images et des étiquettes d'entraînement pour ajuster les pondérations et les biais afin de minimiser la fonction de perte d'entropie croisée. Voici comment cela fonctionne.

L'entropie croisée est une fonction des pondérations, des biais, des pixels de l'image d'entraînement et de sa classe connue.

Si nous calculons les dérivées partielles de l'entropie croisée par rapport à l'ensemble des pondérations et des biais, nous obtenons un "gradient" calculé pour une image, une étiquette et une valeur actuelle donnée de pondérations et de biais. N'oubliez pas que nous pouvons avoir des millions de pondérations et de biais. Calculer le gradient représente beaucoup de travail. Heureusement, TensorFlow le fait pour nous. La propriété mathématique d'un dégradé est qu'il pointe "vers le haut". Puisque nous voulons aller là où l'entropie croisée est faible, nous allons dans la direction opposée. Nous mettons à jour les pondérations et les biais selon une fraction du gradient. Ensuite, nous faisons la même chose encore et encore en utilisant les lots suivants d'images et d'étiquettes d'entraînement dans une boucle d'entraînement. Espérons que cela aboutit à un point où l'entropie croisée est minimale, bien que rien ne garantit que ce minimum soit unique.

gradient descent2.png

Mini-lots et momentum

Vous pouvez calculer votre gradient à partir d'un exemple d'image et mettre immédiatement à jour les pondérations et les biais. Toutefois, avec un lot de 128 images, par exemple, vous obtenez un gradient qui représente mieux les contraintes imposées par les différentes images d'exemple. Il est donc susceptible de converger plus rapidement vers la solution. La taille du mini-lot est un paramètre ajustable.

Cette technique, parfois appelée "descente de gradient stochastique", présente un autre avantage, plus pragmatique: le traitement par lot implique également de travailler avec des matrices plus volumineuses, qui sont généralement plus faciles à optimiser sur les GPU et les TPU.

La convergence peut encore être un peu chaotique et peut même s'arrêter si le vecteur de gradient n'est que des zéros. Cela signifie-t-il que nous avons trouvé un minimum ? Non. Un composant de dégradé peut avoir une valeur minimale ou maximale égale à zéro. Avec un vecteur de gradient comportant des millions d'éléments, s'ils sont tous des zéros, la probabilité que chaque zéro corresponde à un minimum et aucun d'entre eux à un point maximal est assez faible. Dans un espace comportant de nombreuses dimensions, les points d'arrêt sont assez courants et nous ne voulons pas nous en arrêter là.

cc544924671fa208.png

Illustration: un point de selle. Le dégradé est de 0, mais ce n'est pas un minimum dans toutes les directions. (Attribution de l'image Wikimedia: By Nicoguaro - Own work, CC BY 3.0)

La solution consiste à donner une dynamique à l'algorithme d'optimisation afin qu'il puisse passer en selle sans s'arrêter.

Glossaire

lot ou mini-lot: l'entraînement est toujours effectué sur des lots de données d'entraînement et d'étiquettes. Cela contribue à la convergence de l'algorithme. Le "lot" est généralement la première dimension des Tensors de données. Par exemple, un Tensor de forme [100, 192, 192, 3] contient 100 images de 192 x 192 pixels avec trois valeurs par pixel (RVB).

perte d'entropie croisée: fonction de perte spéciale, souvent utilisée dans les classificateurs.

couche dense: couche de neurones où chaque neurone est connecté à tous les neurones de la couche précédente.

features: les entrées d'un réseau de neurones sont parfois appelées "caractéristiques". L'ingénierie des caractéristiques consiste à déterminer quelles parties d'un ensemble de données (ou combinaisons de parties) alimenter un réseau de neurones afin d'obtenir de bonnes prédictions.

labels: autre nom pour "classes". ou les réponses correctes d'un problème de classification supervisée

Taux d'apprentissage: fraction du gradient par laquelle les pondérations et les biais sont mis à jour à chaque itération de la boucle d'entraînement.

logits: les sorties d'une couche de neurones avant l'application de la fonction d'activation sont appelées "logits". Le terme vient de la « fonction logistique » autrement dit la "fonction sigmoïde" qui était autrefois la fonction d'activation la plus populaire. "Sorties de neurones avant la fonction logistique" a été abrégé en "logits".

loss: fonction d'erreur comparant les sorties du réseau de neurones aux bonnes réponses

neurone: calcule la somme pondérée de ses entrées, ajoute un biais et alimente le résultat via une fonction d'activation.

Encodage one-hot: la classe 3 sur 5 est encodée sous la forme d'un vecteur de cinq éléments, tous des zéros à l'exception du troisième, qui est égal à 1.

relu: unité de rectification linéaire. Fonction d'activation populaire utilisée pour les neurones.

sigmoïde: autre fonction d'activation populaire et qui reste utile dans des cas particuliers.

softmax: fonction d'activation spéciale qui agit sur un vecteur, augmente la différence entre la composante la plus grande et les autres, et normalise également le vecteur pour obtenir une somme égale à 1, de sorte qu'il puisse être interprété comme un vecteur de probabilités. Il s'agit de la dernière étape des classificateurs.

tensor: un "Tensor" est semblable à une matrice mais avec un nombre arbitraire de dimensions. Un Tensor unidimensionnel est un vecteur. Un Tensor à deux dimensions est une matrice. Vous pouvez aussi avoir des Tensors ayant au moins 3, 4, 5 dimensions ou plus.

5. Entrons dans le code

Revenons au notebook d'étude et cette fois-ci, lisons le code.

c3df49e90e5a654f.png keras_01_mnist.ipynb

Passons en revue toutes les cellules de ce notebook.

Paramètres des cellules

La taille de lot, le nombre d'époques d'entraînement et l'emplacement des fichiers de données sont définis ici. Les fichiers de données sont hébergés dans un bucket Google Cloud Storage (GCS). C'est pourquoi leur adresse commence par gs://.

Importations de cellules

Toutes les bibliothèques Python nécessaires sont importées ici, y compris TensorFlow et matplotlib pour les visualisations.

Cellule "utilitaires de visualisation [EXÉCUTER ME]****"

Cette cellule contient du code de visualisation inintéressant. Elle est réduite par défaut, mais vous pouvez l'ouvrir et consulter le code lorsque vous en avez le temps en double-cliquant dessus.

Cellule "tf.data.Dataset: parse files and prepare training and validation datasets"

Cette cellule a utilisé l'API tf.data.Dataset pour charger l'ensemble de données MNIST à partir des fichiers de données. Il n'est pas nécessaire de passer trop de temps sur cette cellule. Si l'API tf.data.Dataset vous intéresse, voici un tutoriel qui explique son fonctionnement: Pipelines de données à la vitesse des TPU. Pour l'instant, les bases sont les suivantes:

Les images et les étiquettes (réponses correctes) de l'ensemble de données MNIST sont stockées dans des enregistrements de longueur fixe dans 4 fichiers. Les fichiers peuvent être chargés avec la fonction d'enregistrement fixe dédiée:

imagedataset = tf.data.FixedLengthRecordDataset(image_filename, 28*28, header_bytes=16)

Nous disposons maintenant d'un ensemble de données composé d'octets d'images. Ils doivent être décodés en images. Pour cela, nous définissons une fonction. Comme l'image n'est pas compressée, la fonction n'a pas besoin de décoder quoi que ce soit (decode_raw n'a pratiquement aucun effet). L'image est ensuite convertie en valeurs à virgule flottante comprises entre 0 et 1. Nous pourrions la remodeler ici en tant qu'image 2D, mais en réalité, nous la conservons sous forme d'un tableau plat de pixels de taille 28 x 28, car c'est ce à quoi notre couche dense initiale s'attend.

def read_image(tf_bytestring):
    image = tf.io.decode_raw(tf_bytestring, tf.uint8)
    image = tf.cast(image, tf.float32)/256.0
    image = tf.reshape(image, [28*28])
    return image

Nous appliquons cette fonction à l'ensemble de données à l'aide de .map et obtenons un ensemble de données d'images:

imagedataset = imagedataset.map(read_image, num_parallel_calls=16)

Nous effectuons le même type de lecture et de décodage pour les étiquettes, et nous .zip les images et les étiquettes ensemble:

dataset = tf.data.Dataset.zip((imagedataset, labelsdataset))

Nous avons maintenant un ensemble de données de paires (image, étiquette). C'est ce qu'attend notre modèle. Nous ne sommes pas encore tout à fait prêts à l'utiliser dans la fonction d'entraînement:

dataset = dataset.cache()
dataset = dataset.shuffle(5000, reshuffle_each_iteration=True)
dataset = dataset.repeat()
dataset = dataset.batch(batch_size)
dataset = dataset.prefetch(tf.data.experimental.AUTOTUNE)

L'API tf.data.Dataset dispose de toutes les fonctions utilitaires nécessaires à la préparation des ensembles de données:

.cache met en cache l'ensemble de données dans la RAM. Il s'agit d'un petit jeu de données, donc cela fonctionnera. .shuffle le brasse avec un tampon de 5 000 éléments. Il est important que les données d'entraînement soient bien brassées. .repeat met en boucle l'ensemble de données. Nous allons l'utiliser pour l'entraînement plusieurs fois (plusieurs époques). .batch extrait plusieurs images et étiquettes en un mini-lot. Enfin, .prefetch peut utiliser le processeur pour préparer le lot suivant pendant que le lot actuel est entraîné sur le GPU.

L'ensemble de données de validation est préparé de la même manière. Nous sommes maintenant prêts à définir un modèle et à l'entraîner à l'aide de cet ensemble de données.

Cellule "Modèle Keras"

Tous nos modèles seront des séquences droites de calques afin que nous puissions les créer à l'aide du style tf.keras.Sequential. Au départ, il s'agit d'une seule couche dense. Il contient 10 neurones, car nous classons des chiffres manuscrits en 10 classes. Elle utilise "softmax" l'activation, car il s'agit de la dernière couche d'un classificateur.

Un modèle Keras doit également connaître la forme de ses entrées. tf.keras.layers.Input peut être utilisé pour le définir. Ici, les vecteurs d'entrée sont des vecteurs plats de valeurs de pixels de 28 x 28.

model = tf.keras.Sequential(
  [
    tf.keras.layers.Input(shape=(28*28,)),
    tf.keras.layers.Dense(10, activation='softmax')
  ])

model.compile(optimizer='sgd',
              loss='categorical_crossentropy',
              metrics=['accuracy'])

# print model layers
model.summary()

# utility callback that displays training curves
plot_training = PlotTraining(sample_rate=10, zoom=1)

La configuration du modèle s'effectue dans Keras à l'aide de la fonction model.compile. Dans cet exemple, nous utilisons l'optimiseur de base 'sgd' (descente de gradient stochastique). Un modèle de classification nécessite une fonction de perte d'entropie croisée, appelée 'categorical_crossentropy' dans Keras. Enfin, nous demandons au modèle de calculer la métrique 'accuracy', qui correspond au pourcentage d'images correctement classées.

Keras propose l'utilitaire model.summary(), très utile, qui imprime les détails du modèle que vous avez créé. Votre genre instructeur a ajouté l'utilitaire PlotTraining (défini dans la cellule "visualization tools" [Utilitaires de visualisation]) qui permet d'afficher différentes courbes d'entraînement pendant l'entraînement.

Cellule "Entraîner et valider le modèle"

C'est là que se produit l'entraînement, en appelant model.fit et en transmettant les ensembles de données d'entraînement et de validation. Par défaut, Keras exécute un cycle de validation à la fin de chaque époque.

model.fit(training_dataset, steps_per_epoch=steps_per_epoch, epochs=EPOCHS,
          validation_data=validation_dataset, validation_steps=1,
          callbacks=[plot_training])

Dans Keras, il est possible d'ajouter des comportements personnalisés pendant l'entraînement à l'aide de rappels. C'est ainsi que le tracé d'entraînement mis à jour de manière dynamique a été implémenté pour cet atelier.

Cellule "Visualiser les prédictions"

Une fois le modèle entraîné, nous pouvons en obtenir des prédictions en appelant model.predict():

probabilities = model.predict(font_digits, steps=1)
predicted_labels = np.argmax(probabilities, axis=1)

Ici, nous avons préparé un ensemble de chiffres imprimés rendus à partir de polices locales, à titre de test. Souvenez-vous que le réseau de neurones renvoie un vecteur de 10 probabilités à partir de son "softmax" final. Pour obtenir l'étiquette, nous devons déterminer quelle est la probabilité la plus élevée. C'est ce que fait np.argmax de la bibliothèque Numpy.

Pour comprendre pourquoi le paramètre axis=1 est nécessaire, n'oubliez pas que nous avons traité un lot de 128 images. Par conséquent, le modèle renvoie 128 vecteurs de probabilités. La forme du Tensor de sortie est [128, 10]. Nous calculons l'argmax sur les 10 probabilités renvoyées pour chaque image, soit axis=1 (le premier axe étant 0).

Ce modèle simple reconnaît déjà 90% des chiffres. Pas mal, mais vous allez maintenant améliorer considérablement ce résultat.

396c54ef66fad27f.png

6. Ajouter des calques

godeep.png

Pour améliorer la précision de la reconnaissance, nous allons ajouter des couches au réseau de neurones.

Capture d'écran 27/07/2016 - 15.36.55.png

Nous conservons softmax comme fonction d'activation sur la dernière couche, car c'est ce qui fonctionne le mieux pour la classification. Sur les couches intermédiaires, nous utiliserons la fonction d'activation la plus classique: la fonction sigmoïde:

41fc82288c4aff5d.png

Par exemple, votre modèle pourrait se présenter comme suit (n'oubliez pas les virgules, tf.keras.Sequential accepte une liste de calques séparés par une virgule):

model = tf.keras.Sequential(
  [
      tf.keras.layers.Input(shape=(28*28,)),
      tf.keras.layers.Dense(200, activation='sigmoid'),
      tf.keras.layers.Dense(60, activation='sigmoid'),
      tf.keras.layers.Dense(10, activation='softmax')
  ])

Consultez le "résumé" de votre modèle. Elle contient désormais au moins 10 fois plus de paramètres. Elle devrait être 10 fois meilleure ! Mais pour une raison quelconque, ce n'est pas ...

5236f91ba6e07d85.png

La perte semble aussi avoir explosé le toit. Un problème est survenu.

7. Attention particulière aux réseaux profonds

Vous venez de découvrir les réseaux de neurones, car ils étaient conçus dans les années 80 et 90. Il n'est donc pas étonnant qu'ils abandonnent l'idée et marquent le début de l'"hiver de l'IA". En effet, à mesure que vous ajoutez des couches, les réseaux de neurones ont de plus en plus de difficultés à converger.

Il s'avère que les réseaux de neurones profonds comportant de nombreuses couches (20, 50 ou même 100 aujourd'hui) fonctionnent très bien, à condition de quelques astuces mathématiques pour les faire converger. La découverte de ces astuces simples est l'une des raisons de la renaissance du deep learning dans les années 2010.

Activation RELU

relu.png

La fonction d'activation sigmoïde est en fait assez problématique dans les réseaux profonds. Elle écrase toutes les valeurs comprises entre 0 et 1. Si vous effectuez cette opération à plusieurs reprises, les sorties de neurones et leurs gradients peuvent complètement disparaître. Elle a été mentionnée pour des raisons historiques, mais les réseaux modernes utilisent la fonction RELU (Rectified Linear Unit) qui se présente comme suit:

1abce89f7143a69c.png

En revanche, le relu possède une dérivée de 1, au moins sur le côté droit. Avec l'activation RELU, même si les gradients provenant de certains neurones peuvent être nuls, il y en aura toujours d'autres qui donneront un gradient clair non nul, et l'entraînement se poursuivra à un bon rythme.

Un meilleur optimiseur

Dans les espaces de très grande dimension comme ici (nous avons de l'ordre de 10 000 pondérations et biais), les "points de selle" sont fréquentes. Il s'agit de points qui ne sont pas des minimums locaux, mais où le gradient est néanmoins nul et où l'optimiseur de descente de gradient reste bloqué. TensorFlow propose un large éventail d'optimiseurs, dont certains fonctionnent avec un certain degré d'inertie et dépassent les points de selle en toute sécurité.

Initialisations aléatoires

L'art d'initialiser les biais de pondération avant l'entraînement est un domaine de recherche en soi, avec de nombreux articles publiés à ce sujet. Pour consulter la liste de tous les initialiseurs disponibles dans Keras, cliquez ici. Heureusement, Keras s'en charge par défaut et utilise l'initialiseur 'glorot_uniform', qui est le meilleur dans presque tous les cas.

Vous n'avez rien à faire, car Keras s'en charge.

Nan ???

La formule d'entropie croisée implique un logarithme et log(0) n'est pas un nombre (NaN, un plantage numérique si vous préférez). L'entrée de l'entropie croisée peut-elle être égale à 0 ? L'entrée provient de softmax, qui est essentiellement une valeur exponentielle, et une exponentielle n'est jamais nulle. Nous sommes en sécurité !

Ah bon ? Dans le monde merveilleux des mathématiques, tout serait en sécurité, mais dans le monde informatique, exp(-150), représenté au format float32, est au maximum de zéro et l'entropie croisée plante.

Heureusement, vous n'avez rien à faire non plus ici, car Keras s'en charge et calcule la fonction softmax, suivie de l'entropie croisée, de manière particulièrement prudente afin d'assurer la stabilité numérique et d'éviter les redoutables NaN.

Réussite ?

e1521c9dd936d9bc.png

Vous devriez maintenant obtenir une précision de 97 %. L'objectif de cet atelier est de passer nettement au-dessus des 99 %, alors continuons.

Si vous rencontrez des difficultés, voici la solution à ce stade:

c3df49e90e5a654f.png keras_02_mnist_dense.ipynb

8. Dépréciation du taux d'apprentissage

On peut peut-être essayer de s'entraîner plus vite ? Le taux d'apprentissage par défaut dans l'optimiseur Adam est de 0,001. Essayons de l'augmenter.

Aller plus vite ne semble pas aider beaucoup, et qu'est-ce que tout ce bruit ?

d4fd66346d7c480e.png

Les courbes d'entraînement sont très bruyantes et examinent les deux courbes de validation: elles sautent de haut en bas. Cela signifie que nous allons trop vite. Nous pourrions revenir à la vitesse précédente, mais il existe un meilleur moyen.

Ralentir.png

La bonne solution consiste à démarrer rapidement et à décliner le taux d'apprentissage de manière exponentielle. Dans Keras, vous pouvez le faire à l'aide du rappel tf.keras.callbacks.LearningRateScheduler.

Code utile pour le copier-coller:

# lr decay function
def lr_decay(epoch):
  return 0.01 * math.pow(0.6, epoch)

# lr schedule callback
lr_decay_callback = tf.keras.callbacks.LearningRateScheduler(lr_decay, verbose=True)

# important to see what you are doing
plot_learning_rate(lr_decay, EPOCHS)

N'oubliez pas d'utiliser le lr_decay_callback que vous avez créé. Ajoutez-le à la liste des rappels dans model.fit:

model.fit(...,  callbacks=[plot_training, lr_decay_callback])

L'impact de ce petit changement est spectaculaire. Vous constatez que la majeure partie du bruit a disparu et que la justesse des tests est maintenant supérieure à 98% de façon continue.

8c1ae90976c4a0c1.png

9. Abandon, surapprentissage

Le modèle semble maintenant parfaitement converger. Essayons d'aller encore plus loin.

Est-ce que cela vous aide ?

e36c09a3088104c6.png

Pas vraiment, la justesse est toujours bloquée à 98% et si l'on considère la perte de validation. Ça augmente ! L'algorithme d'apprentissage ne fonctionne que sur les données d'entraînement et optimise la perte d'entraînement en conséquence. Il ne voit jamais de données de validation. Il n'est donc pas surprenant qu'au bout d'un certain temps, son travail n'ait plus d'effet sur la perte de validation, qui cesse d'abandonner et parfois même rebondir.

Cela n'affecte pas immédiatement les capacités de reconnaissance réelles de votre modèle, mais cela vous empêchera d'exécuter de nombreuses itérations et indique généralement que l'entraînement n'a plus d'effet positif.

dropout.png

On parle généralement de "surapprentissage" et quand vous le voyez, essayez d'appliquer une technique de régularisation appelée "abandon". La technique d'abandon tire des neurones aléatoires à chaque itération d'entraînement.

Cela a-t-il fonctionné ?

43fd33801264743f.png

Le bruit réapparaît (sans surprise, étant donné le fonctionnement de l'abandon). La perte de validation ne semble plus s'intensifier, mais elle est globalement plus élevée que sans abandon. Et la justesse de la validation a légèrement diminué. Ce résultat est assez décevant.

Il semble que l'abandon n'était pas la bonne solution, ou peut-être qu'il s'agissait d'un "surapprentissage" est un concept plus complexe et il est impossible d'abandonner certaines de ses causes à corriger ?

Qu'est-ce que le "surapprentissage" ? Le surapprentissage se produit lorsqu'un réseau de neurones apprend "mauvais" d'une manière qui fonctionne pour les exemples d'entraînement, mais pas aussi bien avec des données réelles. Certaines techniques de régularisation, telles que l'abandon, peuvent forcer un apprentissage plus efficace, mais le surapprentissage a aussi des origines plus profondes.

overfitting.png

Le surapprentissage de base se produit lorsqu'un réseau de neurones présente trop de degrés de liberté pour le problème en question. Imaginez que nous ayons tellement de neurones que le réseau puisse y stocker toutes nos images d'entraînement, puis les reconnaître grâce à la correspondance de modèles. Elle échouerait complètement sur des données réelles. Un réseau de neurones doit être quelque peu limité de sorte qu'il soit obligé de généraliser ce qu'il apprend pendant l'entraînement.

Si vous disposez de très peu de données d'entraînement, même un petit réseau peut les apprendre par cœur, ce qui peut entraîner un "surapprentissage". De manière générale, vous avez toujours besoin d'une grande quantité de données pour entraîner des réseaux de neurones.

Enfin, si vous avez suivi toutes les étapes du livre, testé différentes tailles de réseau pour vous assurer que ses degrés de liberté sont limités, appliqué l'abandon et entraîné sur de nombreuses données, vous risquez d'être encore bloqué à un niveau de performance que rien ne semble pouvoir s'améliorer. Cela signifie que votre réseau de neurones, dans sa forme actuelle, n'est pas en mesure d'extraire davantage d'informations de vos données, comme dans le cas présent.

Vous souvenez-vous de la façon dont nous utilisons nos images, aplaties en un seul vecteur ? C'était une très mauvaise idée. Les chiffres manuscrits sont constitués de formes et nous ignorons les informations relatives à ces formes lorsque nous avons aplati les pixels. Toutefois, il existe un type de réseau de neurones qui peut exploiter les informations de forme: les réseaux convolutifs. Essayons-les.

Si vous rencontrez des difficultés, voici la solution à ce stade:

c3df49e90e5a654f.png keras_03_mnist_dense_lrdecay_dropout.ipynb

10. [INFO] Réseaux convolutifs

En résumé

Si vous connaissez déjà tous les termes en gras dans le paragraphe suivant, vous pouvez passer à l'exercice suivant. Si vous débutez avec les réseaux de neurones convolutifs, poursuivez votre lecture.

convolutional.gif

Illustration: Filtrer une image avec deux filtres successifs composés chacun de 4x4x3=48 pondérations apprises.

Voici à quoi ressemble un simple réseau de neurones convolutif dans Keras:

model = tf.keras.Sequential([
    tf.keras.layers.Reshape(input_shape=(28*28,), target_shape=(28, 28, 1)),
    tf.keras.layers.Conv2D(kernel_size=3, filters=12, activation='relu'),
    tf.keras.layers.Conv2D(kernel_size=6, filters=24, strides=2, activation='relu'),
    tf.keras.layers.Conv2D(kernel_size=6, filters=32, strides=2, activation='relu'),
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(10, activation='softmax')
])

688858c21e3beff2.png

Dans une couche d'un réseau convolutif, un "neurone" fait une somme pondérée des pixels juste au-dessus, sur une petite zone de l'image uniquement. Elle ajoute un biais et alimente la somme via une fonction d'activation, tout comme le ferait un neurone d'une couche dense standard. Cette opération est ensuite répétée sur toute l'image avec les mêmes pondérations. Souvenez-vous que dans les couches denses, chaque neurone avait ses propres pondérations. Ici, un seul "patch" de pondérations glisse sur l'image dans les deux sens (une "convolution"). La sortie comporte autant de valeurs qu'il y a de pixels dans l'image (un remplissage est toutefois nécessaire au niveau des bords). Il s'agit d'une opération de filtrage. Dans l'illustration ci-dessus, un filtre de 4 x 4 x 3=48 pondérations est utilisé.

En revanche, 48 pondérations ne seront pas suffisantes. Pour ajouter davantage de degrés de liberté, nous répétons la même opération avec un nouvel ensemble de pondérations. Vous obtenez ainsi un nouvel ensemble de sorties de filtre. Appelons-le un "canal" de sorties par analogie avec les canaux R, V et B de l'image d'entrée.

Capture d'écran 29/07/2016 - 16.02.37.png

Les deux (ou plusieurs) ensembles de pondérations peuvent être additionnés sous la forme d'un seul Tensor en ajoutant une nouvelle dimension. Cela nous donne la forme générique du Tensor des pondérations pour une couche convolutive. Le nombre de canaux d'entrée et de sortie étant des paramètres, nous pouvons commencer à empiler et à chaîner des couches convolutives.

d1b557707bcd1cb9.png

Illustration: Un réseau de neurones convolutif transforme des "cubes" de données dans d'autres "cubes" de données.

Convolutions fractionnées, pooling maximal

En effectuant les convolutions avec un pas de 2 ou 3, nous pouvons également réduire le cube de données résultant dans ses dimensions horizontales. Il existe deux façons courantes de procéder:

  • Convolution échelonnée: un filtre glissant comme ci-dessus, mais avec un pas supérieur à 1
  • Pooling maximal: fenêtre glissante appliquant l'opération MAX (généralement sur 2x2 patchs, répétés tous les 2 pixels)

2b2d4263bb8470b.gif

Illustration: le fait de faire glisser la fenêtre de calcul de 3 pixels réduit le nombre de valeurs de sortie. Les convolutions stylisées ou le pooling maximal (max. sur une fenêtre de 2x2 glissant d'un pas de 2) sont un moyen de réduire le cube de données dans les dimensions horizontales.

La dernière couche

Après la dernière couche convolutive, les données se présentent sous la forme d'un "cube". Il existe deux façons de l'alimenter via la couche dense finale.

La première consiste à aplatir le cube de données dans un vecteur, puis à l'alimenter dans la couche softmax. Parfois, vous pouvez même ajouter une couche dense avant la couche softmax. Le coût est généralement élevé en termes de nombre de pondérations. Une couche dense à l'extrémité d'un réseau convolutif peut contenir plus de la moitié des pondérations de l'ensemble du réseau de neurones.

Au lieu d'utiliser une couche dense coûteuse, nous pouvons aussi diviser les "cubes" de données entrantes en autant de parties que de classes, faire la moyenne de leurs valeurs et les alimenter via une fonction d'activation softmax. Cette méthode de création de la tête de classification ne coûte aucune pondération. Dans Keras, il existe une couche pour cela: tf.keras.layers.GlobalAveragePooling2D().

a44aa392c7b0e32a.png

Passez à la section suivante pour créer un réseau convolutif pour résoudre le problème en question.

11. Un réseau convolutif

Créons un réseau convolutif pour la reconnaissance de chiffres manuscrits. Nous allons utiliser trois couches convolutives en haut, notre couche de lecture softmax traditionnelle en bas, et les connecter avec une couche entièrement connectée:

e1a214a170957da1.png

Notez que les deuxième et troisième couches convolutives présentent un pas de deux, ce qui explique pourquoi elles font passer le nombre de valeurs de sortie de 28 x 28 à 14 x 14, puis à 7 x 7.

Écrivons le code Keras.

Une attention particulière est requise avant la première couche convolutive. Il s'attend d'ailleurs à voir un "cube" en 3D Toutefois, notre ensemble de données a jusqu'à présent été configuré pour des couches denses, et tous les pixels des images sont aplatis en un vecteur. Nous devons les remodeler en images de 28 x 28 x 1 (un canal pour les images en niveaux de gris):

tf.keras.layers.Reshape(input_shape=(28*28,), target_shape=(28, 28, 1))

Vous pouvez utiliser cette ligne à la place du calque tf.keras.layers.Input dont vous disposiez jusqu'à présent.

Dans Keras, la syntaxe d'une couche convolutive activée par Relu est la suivante:

140f80336b0e653b.png

tf.keras.layers.Conv2D(kernel_size=3, filters=12, padding='same', activation='relu')

Pour une convolution avancée, vous écririez:

tf.keras.layers.Conv2D(kernel_size=6, filters=24, padding='same', activation='relu', strides=2)

Pour aplatir un cube de données dans un vecteur afin qu'il puisse être utilisé par une couche dense:

tf.keras.layers.Flatten()

Pour la couche dense, la syntaxe n'a pas changé:

tf.keras.layers.Dense(200, activation='relu')

Votre modèle a-t-il franchi la barrière de justesse de 99 % ? Presque... mais regardons la courbe de fonction de perte de validation. Ces problématiques vous semblent-elles familières ?

ecc5972814885226.png

Examinez aussi les prédictions. Pour la première fois, vous devriez constater que la plupart des 10 000 chiffres de test sont maintenant correctement reconnus. Il ne reste plus qu'environ 4,5 lignes de détection incorrecte (environ 110 chiffres sur 10 000)

37e4cbd3f390c89e.png

Si vous rencontrez des difficultés, voici la solution à ce stade:

c3df49e90e5a654f.png keras_04_mnist_convolutional.ipynb

12. Abandon à nouveau

La formation précédente montre des signes évidents de surapprentissage (la justesse reste inférieure à 99 %). Devons-nous réessayer l'abandon ?

Comment ça s'est passé cette fois-ci ?

63e0cc982cee2030.png

Il semble que l'abandon ait fonctionné cette fois-ci. La perte de validation n'augmente plus, et la justesse finale devrait être largement supérieure à 99%. Félicitations !

La première fois que nous avons essayé d'appliquer l'abandon, nous avons pensé que nous avions un problème de surapprentissage, alors qu'en fait il résidait dans l'architecture du réseau de neurones. Nous ne pourrions pas aller plus loin sans couches convolutives, et l'abandon ne pourrait rien faire à ce sujet.

Cette fois, le problème semble avoir été dû au surapprentissage et l'abandon a été résolu. N'oubliez pas que de nombreux éléments peuvent entraîner une divergence entre les courbes de perte d'entraînement et de validation, entraînant une augmentation progressive de la perte de validation. Le surapprentissage (trop de degrés de liberté, mal utilisé par le réseau) n'est qu'un de ces facteurs. Si votre ensemble de données est trop petit ou si l'architecture de votre réseau de neurones n'est pas adéquate, vous pouvez constater un comportement similaire sur les courbes de fonction de perte, mais l'abandon ne vous aide pas.

13. Normalisation par lots

oggEbikl2I6_sOo7FlaX2KLdNeaYhJnVSS8GyG8FHXid75PVJX73CRiOynwpMZpLZq6_xAy69wgyez5T-ZlpuC2XSlcmjk7oVcOzefKKTFhTEoLO3kljz2RDyKcaFtHvtTey-I4VpQ

Enfin, essayons d'ajouter la normalisation des lots.

C'est la théorie. En pratique, rappelez-vous simplement quelques règles:

Pour l'instant, examinons ce livre et ajoutons une couche de norme de lot sur chaque couche du réseau de neurones, à l'exception de la dernière. Ne l'ajoutez pas à la dernière valeur "softmax" couche de données. Elle ne serait pas utile.

# Modify each layer: remove the activation from the layer itself.
# Set use_bias=False since batch norm will play the role of biases.
tf.keras.layers.Conv2D(..., use_bias=False),
# Batch norm goes between the layer and its activation.
# The scale factor can be turned off for Relu activation.
tf.keras.layers.BatchNormalization(scale=False, center=True),
# Finish with the activation.
tf.keras.layers.Activation('relu'),

Quel est le degré de précision actuel ?

ea48193334c565a1.png

Avec quelques ajustements (BATCH_SIZE=64, paramètre de décroissance du taux d'apprentissage 0,666, taux d'abandon sur la couche dense 0.3) et un peu de chance, vous pouvez atteindre 99,5%. Les ajustements du taux d'apprentissage et d'abandon ont été effectués conformément aux "bonnes pratiques" pour utiliser la norme de lot:

  • La norme de lot aide les réseaux de neurones à converger et permet généralement d'entraîner des modèles plus rapidement.
  • La norme de lot est un régulateur. Vous pouvez généralement réduire le nombre d’abandons que vous utilisez, voire même ne pas utiliser du tout.

Le notebook de la solution a une durée d'entraînement de 99,5 % :

c3df49e90e5a654f.png keras_05_mnist_batch_norm.ipynb

14. Entraîner des modèles dans le cloud sur du matériel puissant: AI Platform

d7d0282e687bdad8.png

Vous trouverez une version cloud du code dans le dossier MLEngine sur GitHub, ainsi que des instructions pour l'exécuter sur Google Cloud AI Platform. Avant de pouvoir exécuter cette partie, vous devez créer un compte Google Cloud et activer la facturation. Les ressources nécessaires pour réaliser l'atelier ne devraient pas dépasser 2 $ (en supposant une heure d'entraînement sur un GPU). Pour préparer votre compte:

  1. Créez un projet Google Cloud Platform ( http://cloud.google.com/console).
  2. Activez la facturation.
  3. Installez les outils de ligne de commande GCP ( cliquez ici pour accéder au SDK GCP).
  4. Créez un bucket Google Cloud Storage (dans la région us-central1). Il servira à préproduire le code d'entraînement et à stocker votre modèle entraîné.
  5. Activez les API nécessaires et demandez les quotas nécessaires (exécutez une fois la commande d'entraînement et vous devriez obtenir des messages d'erreur vous indiquant ce que vous devez activer).

15. Félicitations !

Vous avez créé votre premier réseau de neurones et vous l'avez entraîné jusqu'à atteindre une précision de 99 %. Les techniques apprises en cours de route ne sont pas spécifiques à l'ensemble de données MNIST, mais sont en fait largement utilisées pour travailler avec des réseaux de neurones. Voici les "notes de falaise" en cadeau. de l'atelier, en version dessin animé. Vous pouvez l'utiliser pour vous souvenir de ce que vous avez appris:

Falaises Notes TensorFlow lab.png

Étapes suivantes

  • Après avoir créé des réseaux convolutifs et entièrement connectés, vous devriez jeter un œil aux réseaux de neurones récurrents.
  • Pour exécuter votre entraînement ou votre inférence dans le cloud sur une infrastructure distribuée, Google Cloud fournit AI Platform.
  • Enfin, les commentaires sont les bienvenus. Veuillez nous indiquer si vous constatez une anomalie dans cet atelier ou si vous pensez qu'elle doit être améliorée. Nous traitons les commentaires via GitHub [lien de commentaires].

HR.png

Identifiant Martin Görner small.jpgAuteur: Martin GörnerTwitter: @martin_gorner

Tous les dessins animés protégés par les droits d'auteur de cet atelier: alexpokusay / 123RF banque de photos