Prototyper un réseau de neurones avec Keras
Prototyper un réseau de neurones avec Keras
L’objet de cet article sera de vous montrer comment manipuler quelques briques de base pour POC-er un réseau de neurones vite fait bien fait avec Keras, comment le customiser avec un backend de Keras, et débriefer sur certains points d’attention propres à un projet deep-learning.
Keras est une surcouche user-friendly pour prototyper rapidement un réseau de neurones, comme une sorte de gros Lego©.
Son concepteur et principal développeur - François Chollet, est ingénieur chez Google, éditeur d'un des principaux backend de Keras : Tensorflow.
Quand on entend « surcouche », « user-friendly », « prototype », on peut se dire que c’est forcément limitant, mais en fait pas vraiment.
Si on veut raffiner ce que la surcouche propose :
- Keras possède sa Keras functional API, pour créer des modèles plus complexes que ceux proposés par les briques de base, déjà très nombreuses et très riches.
Par exemple pour faire du multi-input, créer des couches non totalement connectées, ou partagées, opérer des « courts-circuits » dans le graphe de votre modèle, faire du multi-output, et probablement la plupart des choses qui pourraient vous passer par la tête quand vous concevez votre modèle.
- Keras utilise pour les opérations bas niveau (manipulation de tenseurs, produits de convolution etc.) des librairies (backend engines) bien optimisées pour ça, dans lesquelles il est aussi possible de plonger pour fignoler encore.
Il s’interface pour ça de façon transparente avec :
. Theano
. CNTK (Cognitive Toolkit de Microsoft)
Note : Les exemples fournis vont chercher en ligne les données dont ils ont besoin, vous pouvez les jouer tels quels, pourvu que vous ayez installé Python 3.xx et les librairies utilisées dans les exemples. Si vous installez Keras, je vous recommande d’installer Tensorflow avant.
Prototype : Reconnaissance de chiffres manuscrits
Le projet
Un exemple de reconnaissance d’images tiré de cet article.
On projette de pouvoir reconnaître automatiquement, suite à un scan, les chiffres manuscrits au dos des enveloppes.
Les données
Pour ça on va se procurer un jeu de données MNIST de caractères manuscrits déjà qualifiés (labelled) à la main. Les images sont en noir et blanc, 32 pixels par 32 pixels.
Le MNIST utilisé est celui mis à disposition par Yann Lecun, un autre frenchy. (Qui accessoirement a popularisé le deep-learning au milieu des années 2010, et qui est aussi un ancien post-doctorant de Geoffrey Hinton, un des contributeurs les plus créatifs et les plus féconds de la galaxie "deep-learning").
Quelques concepts utiles (Facultatif)
Les réseaux de neurones
Un neurone formel (artificiel) est une boite qui prend un ensemble de "poids" en entrée, les passe dans une/des fonction(s) - "g" dans l'exemple ci-dessous - qui transforme(nt) ce signal, et ressort à son tour le signal transformé. Un exemple ci-après.
Exemple de neurone formel
Un réseau de neurones est un maillage de neurones formels. Cf. illustration ci-dessous comme exemple.
Exemple de réseau de neurones
Nous allons dans notre cas utiliser un réseau de neurones qui "empile" :
- Deux couches de convolution
- Une couche de pooling
Les couches Dropout et Flatten sont là pour respectivement éteindre une fraction des neurones au hasard afin d'éviter le sur-apprentissage, puis pour la Flattent recaler la sortie du dropout au bon format pour la couche suivante.
- Une couche "Dense" qui est une couche de neurones classique, complètement connectée avec la couche précédente et la couche suivante.
- Une couche dense en sortie avec un neurone par classe, et une fonction d'activation "softmax" qui donne une probabilité (dont la somme vaut 1) en sortie de chaque neurone, le neurone de sortie avec la probabilité la plus grande permettant alors de décider que sa classe associée est la classe prédite.
Topologie du réseau de neurones que nous allons utiliser
La représentation des données
Les données (images) sont représentées par des tableaux de chiffres prenant pour valeurs le niveau de gris de chaque pixel.
Le souci c'est qu'on peut rencontrer des difficultés à comparer deux images ainsi décrites, dont la forme à l’œil est proche mais dont les représentations en tableaux de chiffres sont éloignées. Deux objets "proches" peuvent avoir des descriptions très différentes et vice-versa.
Les illustrations ci-dessous se proposent de montrer comment on peut gérer ce problème.
- Tout d'abord, la représentation de nos images en tableaux de chiffres pour la machine.
Cette représentation donne des tableaux très différents, et donc pour la machine ces trois images, qui pourtant présentent une même forme (vous avez tous reconnu un "A"), sont "vues" comme très dissemblables.
Comment faire alors pour offrir à la machine des "vues" de ces images qui soient 1/ suffisamment proches entre elles lorsqu'il s'agit du même caractère, 2/ suffisamment éloignées entre elles lorsqu'il s'agit de caractères différents ?
Pour cela on va appliquer à chaque image les mêmes produits de convolution (généralement avec plusieurs kernels *), et une étape de pooling **
* Ici un kernel est un tableau de chiffres (possiblement 1D, 2D, 3D etc... et dans notre cas 2D) avec lequel on fait un produit de convolution avec une partie de l'image de même dimension. Ce produit va opérer comme une sorte de "filtre" sur l'image.
Pour une entrée en matière sur les produits de convolution dans le traitement des images c'est par ici, et sur les produits de convolution en général c'est par là.
** Le pooling lui aussi concentre l'information la plus intéressante de l'image, rendant la représentation qu'il produit plus résistante à certaines déformations, tout en permettant d'accélérer l'apprentissage.
Illustration du processus de représentation des images après convolution > pooling
Cette représentation donne quelque chose de "séparable" entre les divers caractères. Ce sera exploitable par les couches finales de notre réseau de neurones qui pourront les classer avec une certaine fiabilité.
Les couches finales, ou couches de projection, sont des couches classiques de neurones simples, c'est à dire possédant une fonction d'activation type sigmoïde, softmax, ReLU ou tanh, et prenant en entrée toute les sorties des neurones précédents (couche complètement connectée). Ce sont ces couches qui vont vraiment apprendre à classifier.
Le deep-learning
Sur des réseaux peu profonds, en amont et faisant partie intégrante de la « préparation des données », un expert métier fournit les caractéristiques représentatives des données. Par exemple, un caractère manuscrit ce sont des traits, des ronds, une taille min et une taille max etc…
Dans les réseaux profonds, c’est la machine qui va elle-même déterminer ces caractéristiques en fonction des données. Si on observe la sortie de chaque couche, on s’aperçoit que chaque couche détecte des « caractéristiques » de plus ou moins bas niveau en fonction de sa profondeur dans le réseau.
Dans la configuration ci-dessous, les couches proches de l’entrée vont détecter les features de haut niveau (celles qui sont encore presque une vraie image), et plus on s’enfonce dans le réseau, plus les neurones vont se spécialiser sur des features de plus bas niveau par exemple des segments, des arrondis, des formes et couleurs élémentaires composant une image.
Mais le mieux est peut-être d’illustrer ça avec une image qui soit raccord avec notre jeu de données (pour la couleur Google vous en fournira d’autres).
Ce que "voient" les neurones de chaque couche dans un réseau profond
En version animée
Le code
Comme précisé précédemment, si vous avez un environnement Python 3.xx et Tensorflow installé avec Keras, le code ci-dessous doit pouvoir se jouer tel-quel.
import keras
from keras.datasets import mnist
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPooling2D
import numpy as np
batch_size = 128
num_classes = 10
epochs = 12
# input image dimensions
img_rows, img_cols = 28, 28
# the data, split between train and test sets
(x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train = x_train.reshape(60000,28,28,1)
x_test = x_test.reshape(10000,28,28,1)
print('x_train shape:', x_train.shape)
print(x_train.shape[0], 'train samples')
print(x_test.shape[0], 'test samples')
# convert class vectors to binary class matrices
y_train = keras.utils.to_categorical(y_train, num_classes)
y_test = keras.utils.to_categorical(y_test, num_classes)
model = Sequential()
model.add(Conv2D(32, kernel_size=(3, 3),
activation='relu',
input_shape=(28,28,1)))
model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(128, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(num_classes, activation='softmax'))
model.compile(loss=keras.losses.categorical_crossentropy,
optimizer=keras.optimizers.Adadelta(),
metrics=['accuracy'])
model.fit(x_train, y_train,
batch_size=batch_size,
epochs=epochs,
verbose=1,
validation_data=(x_test, y_test))
score = model.evaluate(x_test, y_test, verbose=0)
print('Test loss:', score[0])
print('Test accuracy:', score[1])
Ce prototype donne une prédiction exacte à 0.9908 sur le jeu de test.
Note : L'attribution des poids initiaux se fait random - ça évite les symétries qui font que le réseau n'apprend plus rien - et ce random change les résultats à quelques chiffres après la virgule. Donc rien de significatif mais si vous jouez le code ci-dessus tel quel, ce n'est pas dit que vous ayez exactement la même chose.
Voilà vous êtes arrivés.
La suite c'est en option, pour montrer quelques possibilités supplémentaires.
Fignolage
Un graphe plus "exotique"
Et pourquoi pas paralléliser les couches de convolution avant le pooling et voir ce que donne cette configuration?
Dans notre cas c'est assez inutile et probablement contre-productif, on y perd l'intérêt de convolutionner plusieurs fois. Ceci-dit j'ai noté le résultat du modèle précédent ci-dessus et nous allons donc comparer.
Graphe :
Topologie de notre nouveau prototype
Code :
Pour ce faire, je ne vais pas utiliser le modèle séquentiel de Keras mais le modèle fonctionnel (nous avions mentionné la "functional API" précédemment). Ce dernier est moins immédiat à mettre en place, mais beaucoup plus souple.
Il existe plusieurs façons de merger le résultat de deux couches, ici nous allons simplement concaténer les deux tenseurs qui sortent de CONV2D_1 et CONV2D_2. Les dimensions, en dehors de l'axe sur lequel on concatène, doivent être compatibles. Ici on a 26x26x32 et 26x26x64, ce qui donnera du 26x26x96, on est OK.
import keras
from keras.datasets import mnist
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Input
from keras.utils import plot_model
from keras.models import Model
from keras.layers.merge import concatenate
import numpy as np
batch_size = 128
num_classes = 10
epochs = 12
# input image dimensions
img_rows, img_cols = 28, 28
# the data, split between train and test sets
(x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train = x_train.reshape(60000,28,28,1)
x_test = x_test.reshape(10000,28,28,1)
print('x_train shape:', x_train.shape)
print(x_train.shape[0], 'train samples')
print(x_test.shape[0], 'test samples')
# convert class vectors to binary class matrices
y_train = keras.utils.to_categorical(y_train, num_classes)
y_test = keras.utils.to_categorical(y_test, num_classes)
# input layer
entree = Input(shape=(28,28,1))
# Ici une première branche
conv1 = Conv2D(32, kernel_size=(3, 3), activation='relu', input_shape=(32,32,1))(entree)
# Ici la 2ème branche
conv2 = Conv2D(64, (3, 3), activation='relu', input_shape=(32,32,1))(entree)
# Et là on re-merge
merge = concatenate([conv1, conv2])
# Et on repart comme pour le précédent, mais avec le modèle fonctionnel
pool1 = MaxPooling2D(pool_size=(2, 2))(merge)
dropout1 = Dropout(0.25)(pool1)
flatten1 = Flatten()(dropout1)
#Couche d'interprétation, celle qui classe en sortie des transformations type convolution/maxPool etc...
hidden1 = Dense(128, activation='relu')(flatten1)
dropout2 = Dropout(0.25)(hidden1)
sortie = Dense(num_classes, activation='softmax')(dropout2)
model = Model(inputs=entree, outputs=sortie)
model.compile(loss=keras.losses.categorical_crossentropy,
optimizer=keras.optimizers.Adadelta(),
metrics=['accuracy'])
model.fit(x_train, y_train,
batch_size=batch_size,
epochs=epochs,
verbose=1,
validation_data=(x_test, y_test))
score = model.evaluate(x_test, y_test, verbose=0)
print('Test loss:', score[0])
print('Test accuracy:', score[1])
Ce prototype donne une prédiction exacte à 0.9858 sur le jeu de test.
Ça marche moins bien mais la différence n'est pas aussi nette que je ne l'imaginais au début, au moins une des deux couches apporte des informations suffisamment pertinentes pour que les couches "Dense" fassent le travail de classification correctement.
Note : Pour faire du multi-output par exemple, la syntaxe sera :
- Lors de la définition du modèle : model = Model(inputs=entree, outputs=[sortie1,sortie2])
- Lors de l'apprentissage : model.fit(x_train, [y_train_sortie1, y_train_sortie2], ....)
Utilisation du backend (utilisation de Tensorflow au travers de Keras)
Par exemple je veux une fonction d'activation custom pour la dernière couche de mon réseau, je peux définir cette fonction et ma couche de sortie de cette façon :
# A ajouter AVANT la définition du modèle
from keras import backend as Ke #Notre backend ici c'est TensorFlow
def custom_activation(x):
#Par exemple ici une softmax qui favorise la première classe et défavorise la dernière
v = np.array([0.01,0,0,0,0,0,0,0,0,-0.01])
return (Ke.softmax(x) + v)
from keras.utils import CustomObjectScope
# A mettre en remplacement de la couche "sortie = Dense(num_classes, activation='softmax')(dropout2)" du snippet précédent
with CustomObjectScope({'custom_activation':custom_activation}):
sortie = Dense(num_classes, activation='custom_activation')(dropout2)
Ce prototype donne une prédiction exacte à 0.9867 sur le jeu de test. C'est à peu près ce qu'on avait avant, favoriser la première classe de 0.01 par rapport à la 10-ème et dernière n'a aucune influence sur les capacités de notre classifieur, la séparation entre les caractères sur une échelle de 0 à 1 est sans doute plus "nette" qu'un 10^-2.
Et pourquoi pas une couche custom ?
Note : Pour les opérations simples de ce type, il existe déjà une couche Lambda proposée par Keras. Nous proposons de montrer ci-après comment aller plus loin.
Vous pouvez trouver l'inspiration avec le support des couches existantes, dont vous trouverez des sources ici. Par exemple, pour voir ce qu'il se passe dans une couche Dense, Ctrl + F > class Dense(Layer).
# Remplacez "with CustomObjectScope({'custom_activation':custom_activation}):
# sortie = Dense(num_classes, activation='custom_activation')(dropout2)"
#du code précédent par le code ci-dessous
#NOTE : On ajoute une couche custom qui fait le produit du tenseurs d'entrée (tenseur en sortie de dropout2 - dimension 128)
# et du tenseur contenant les poids "modifiables" de la couche "melayer" (dimension 10),
# lui même construit avec la méthode build de la classe MyLayer.
# C'est l'équivalent d'une couche Dense, sans biais et sans fonction d'activation (enfin si, y = x, inchangé)
# Du coup à part ralentir un peu l'apprentissage, et encore sur 12 époques (une époque c'est le passage de l'ensemble des
# données d'apprentissage dans le réseau et leur rétro-propagation) ce sera probablement invisible.
# On aura sans doute un résultat proche de ce qu'on aurait avec une couche "Dense" à 10 sorties en lieu et place de "melayer".
# Je ferais le test dans les 2 cas (Dense de dimension 10 vs. melayer) et mettrais le résultat juste en dessous.
# Pour le reste on peut tweaker la logique de la couche autant qu'on veut dans la partie call()
from keras.layers import Layer
class MyLayer(Layer):
def __init__(self, output_dim, **kwargs):
self.output_dim = output_dim
super(MyLayer, self).__init__(**kwargs)
def build(self, input_shape):
# Create a trainable weight variable for this layer.
self.kernel = self.add_weight(name='kernel',
shape=(input_shape[1], self.output_dim),
initializer='uniform',
trainable=True)
super(MyLayer, self).build(input_shape) # Be sure to call this at the end
def call(self, x): #Ici toute la logique de la nouvelle couche
return Ke.dot(x, self.kernel)
def compute_output_shape(self, input_shape):
return (input_shape[0], self.output_dim)
melayer = MyLayer(num_classes)(dropout2)
with CustomObjectScope({'custom_activation':custom_activation}):
sortie = Dense(num_classes, activation='custom_activation')(melayer)
Ce prototype donne une prédiction exacte à 0.9852 sur le jeu de test.
En remplaçant la couche custom par une couche Dense de dimension 10, on a une accuracy de 0.9823, c'est kif-kif, en même temps notre couche custom ne fait pas grand chose...
Culture G. : Keras possède son propre graphe de calcul, distinct de celui du backend. Il y a 3 classes majeures dans la topologie de Keras, qu'il est intéressant d'avoir en tête :
. Layer : Encapsule les poids (modifiables ou non, on peut choisir de figer tout ou partie du modèle pour qu'il ne soit pas "entraînable"/capable d'apprendre), les éléments de calcul, une logique qui lui est propre et que l'on voit en filigrane des paramètres que l'on peut passer quand on l'appelle.
Une couche possède aussi des inbound_nodes et des outbound_nodes qui gèrent la connexion aux couches amont ou aval (on va en reparler), vous pouvez aller regarder ici dans la classe "Layer" si ça vous dit.
. Les "nodes" : ils représentent les connections entre les couches. Ils sont associés à des informations type : de quelles couches viennent les tenseurs d'entrée, et où vont elles ensuite, avec quelles dimensions etc...
A chaque connexion entre deux couches, un outbound_node est créé dans la couche amont, et un inbound_node dans la couche aval.
. Container : Il contient une pile de couches et représente la topologie du modèle. Il assure la rétro-propagation des gradients, la circulation générale du flux de données dans le réseau, par exemple lorsqu'il s’entraîne.
Considérations générales
Voilà pour conclure, j'aimerais souligner quelques points sur un projet Deep-learning :
- Je vous recommande d'apporter un soin tout particulier à la mesure de vos résultats. Tout d'abord parce-qu’on peut vite se perdre dans les nombreux paramètres et réglages, et ensuite parce-que si vous posez une mesure incorrecte et que votre modèle semble dysfonctionner, suivant votre intuition vous allez sans doute perdre beaucoup de temps à chercher d'où viennent les erreurs dans les données, dans votre graphe, dans ses paramètres etc... avant de remettre en question la façon de mesurer elle même.
Exemples de mesures :
- Loss, accuracy, precision, recall, à chaque époque et même à chaque mini-batch
- Durée de vos itérations et de vos époques
- Observer quelques valeurs en sortie sur un échantillon de test
- Le travail sur les données en amont, leur observation, analyse, nettoyage. C'est une étape moins mise en valeur, bien qu'à la fois essentielle et très intéressante de ce genre de projet.
- Et puis l'importance du métier pour définir de nouvelles features pour renforcer l'apprentissage. En deep-learning, c'est normalement la machine qui trouve elle même les features, mais elle ne les trouvera pas toutes. Certaines features évidentes pour un expert métier ne le sont pas pour la machine, et vice-versa. Les deux sont complémentaires..
Pour une vue plus générale à propos du machine learning, dont le deep-learning est une sous-partie, je vous invite à consulter cet article qui en fait un bon résumé, et quelques exemples de code ici.
Commentaires
Enregistrer un commentaire