Réseau récurrent pour faire de la traduction.

Ce poste est un tutoriel pour coder une architecture basique d’un réseau de neurones qui traduit un texte d’une langue à un autre. Il s’adresse à des lecteurs désireux d’apprendre le Machine Learning et qui ne connaissent pas ou peu les cellules récurrentes. Cependant on suppose que le lecteur connaît déjà les principes de base des réseaux de neurones (backpropagation, fonction d’activation, perceptron multicouche) ainsi que l’utilisation de la librairie keras (en particulier l’utilisation des Tensor, Layer et Model).

Après avoir introduit les données d’entraînement, nous présenterons le principe des réseau de neurones récurrents. C’est un type de réseau qui utilise des opérations récursives (appelées Recurent Layer en anglais), et qui est adapté à tout type de données présentant une composante temporelle ou timestep. Ensuite nous présenterons l’architecture du réseau basé sur un encodeur et un décodeur. Et finalement nous verrons comment l’entraîner et faire une inférence.

Préparation des données d’entrainement

Nous allons nous intéresser à la traduction de l’anglais vers le français. Il nous faut donc un jeux de données qui contienne des phrases en anglais avec leurs traductions françaises. On peux obtenir un tel jeu de manière gratuite sur le site suivant http://www.manythings.org/anki/.

Chargement en DataFrame

On téléchargera le fichier fra-eng.zip. C’est un fichier .txt de type tsv (Tabulation-separated values), c’est-à-dire que les colonnes sont séparées par une tabulation. Il y a trois colonnes : la phrase en anglais, la phrase en français et une indication sur l’attribution de la traduction. Cette dernière colonne ne nous servira pas.

Ensuite nous allons importer les données sous forme d’un pandas.DataFrame. Comme il y a des phrases très longues dans les données (plus de 300 caractères!), pour simplifier la présentation on ne prendra que les dix milles premières lignes, ce qui limite les phrases à environ 30 caractères.

1
2
3
4
import pandas

Data = pandas.read_csv("fra.txt", sep = "\t", names = ["EN","FR"], usecols=[0,1], nrows=10000)
print(Data.sample(50))   ## Affichage d'un échantillon des données
                   EN                                       FR
3465    Are you tall?                           Es-tu grande ?
3178     We must run.                     Il nous faut courir.
3759    I can't sing.                  Je ne sais pas chanter.
7922  Don't tease me.                      Ne me taquine pas !
5521   He pinched me!                           Il m'a pincé !
5729   I fed the dog.                    J'ai nourri le chien.
1691      Let's talk.                              Discutons !
8597  I need it ASAP.  J'en ai besoin aussi vite que possible.
9075  It didn't help.                       Cela n'a pas aidé.
4424    Ladies first.                      Les femmes d'abord.
582        Come here.                                Venez là.
8885  I'm going bald.                       Je deviens chauve.
480         Say what?                                De quoi ?
694        I give up.                             J'abandonne.
198          Keep it.                              Gardez-le !
5139    You're upset.                        Tu es contrariée.
7360   Were you hurt?                       As-tu été blessé ?
2779     It's my job.                        C'est mon emploi.
2007     Are you Tom?                          Êtes-vous Tom ?
4826    Tom's stoned.                           Tom est stone.
1888      We like it.                       Nous l'apprécions.
7719  Can you juggle?                     Savez-vous jongler ?
4793    Tom is there.                          Tom est là-bas.
8936  I'm not skinny.             Je ne suis pas maigrichonne.
9087  It looked real.                     Ça avait l'air réel.
380         I talked.                              J’ai parlé.
9433  She came alone.                    Elle est venue seule.
3419    Are you busy?                          Es-tu occupée ?
9008  I've messed up.                              J'ai merdé.
8277  I can't see it.                  Je ne peux pas la voir.
3435    Are you hurt?                         T'es-tu blessé ?
7844  Do you miss it?                           Ça te manque ?
4104    I'm a surfer.                        Je suis surfeuse.
300         Drive on.                      Continue à rouler !
4857    We all cried.                  Nous avons tous pleuré.
2486     I waited up.                             J'ai veillé.
5476   He got caught.                   Il s'est fait prendre.
8778  I won't forget.                      Je n'oublierai pas.
4825    Tom's stoned.                         Tom est défoncé.
4141    I'm homesick.                     J'ai le mal du pays.
8237  I am too short.                      Je suis trop petit.
2638     I'm not sad.                   Je ne suis pas triste.
8992  I'm very tired.                    Je suis très fatigué.
6104   I wasn't busy.                   Je n'étais pas occupé.
5594   Hold the door.                       Retiens la porte !
3231     What a bore!                      Quelle emmerdeuse !
5658   I can buy one.                  Je peux en acheter une.
8219  How's your son?                    Comment va ton fils ?
1761      Stay still.                            Ne bouge pas.
5152   A car hit Tom.      Tom a été renversé par une voiture.

Encodage des données

Afin de faire passer ces données dans un réseau de neurones, il faut les transformer en des vecteurs de nombres. Pour cela, nous allons considérer chaque caractère comme un jeton (token en anglais) et le coder par un nombre, sachant que les espaces et les ponctuations comptent pour un caractère. De plus on ne fera pas de distinction en une lettre minuscule et sa lettre majuscule.

Heureusement la librairie keras admet la classe keras.preprocessing.text.Tokenizer permettant d’effectuer cette opération. Un Tokenizer est un dictionnaire d’encodage de mots ou de caractères à partir d’un corpus de textes et admet des méthodes pour faire la convertion de « mot/caractère » vers « code » et vice-versa.

D’abord avant d’utiliser le Tokenizer, il faut créer deux jetons spéciaux l’un indiquant le début d’une phrase et l’autre indiquant la fin (les raisons de la nécessité de ces jetons seront expliquées plus bas). Comme les caractères ‘\t’ et ‘\n’ ne sont jamais utilisés, on prendra le premier comme jeton indiquant le début et l’autre comme indiquant la fin. Ensuite il instancier un Tokenizer avec ses paramètres, puis appeler la méthode .fit sur un corpus de texte, qui est une liste de string, pour créer le dictionnaire.

L’instanciation du Tokenizer se fait avec de nombreux paramètres, mais les plus importants sont les suivants :

  • lower (booléen, True par défaut) : si True les lettres majuscules sont considéreés comme des lettres minuscules.
  • char_level (booléen, False par défaut) : si True chaque caractère est considéré comme un jeton et si False chaque mot est considéré comme un jeton.
1
2
3
4
5
6
7
import keras

Data['EN'] = Data['EN'].apply( lambda x : '\t{}\n'.format(x) )  ## Ajout des jetons de début et  de fin
Data['FR'] = Data['FR'].apply( lambda x : '\t{}\n'.format(x) )  ## Ajout des jetons de début et  de fin

Tokenizer = keras.preprocessing.text.Tokenizer(lower=True, char_level=True)
Tokenizer.fit_on_texts(list(Data['EN']) + list(Data['FR']))

Une fois le Tokenizer construit, on peut récupérer le dictionnaire via l’attribut .word_index.

1
print(Tokenizer.word_index)
{' ': 1, 'e': 2, 's': 3, 'i': 4, '\t': 5, '\n': 6, 't': 7, 'a': 8, 'o': 9, '.': 10, 'n': 11, 'u': 12, 'r': 13,
'l': 14, 'm': 15, 'd': 16, 'c': 17, "'": 18, 'h': 19, 'p': 20, 'v': 21, 'y': 22, 'j': 23, 'g': 24, 'é': 25,
'w': 26, 'f': 27, '?': 28, 'b': 29, '!': 30, 'k': 31, '-': 32, 'z': 33, 'q': 34, 'x': 35, 'ê': 36, 'à': 37,
'\u202f': 38, 'ç': 39, 'è': 40, ',': 41, '\xa0': 42, 'û': 43, 'â': 44, 'î': 45, 'ô': 46, '’': 47, 'ï': 48,
'ù': 49, '\u2009': 50, 'œ': 51, '0': 52, '9': 53, '3': 54, ':': 55, '1': 56, '«': 57, '»': 58, '5': 59,
'%': 60, '8': 61, '$': 62, '&': 63, '(': 64, ')': 65, 'ë': 66, '7': 67, '6': 68}

Ainsi par exemple l’espace sera codé par 1, la lettre e par 2, la lettre s par 3, etc… À noter que l’on devrait faire un nettoyage des données (data cleaning) pour avoir des données plus consistants, car par exemple on peut voir qu’il y a des caractères parasites : ‘\u202f’ (narrow no-break space), ‘\xa0’ (non-breaking space in Latin1), etc… Mais pour faire simple, nous nous passerons cette étape.

Le Tokenizer admet la méthode texts_to_sequences permettant d’encoder un corpus de textes que nous allons utiliser pour créer les données à faire passer dans le réseau de neuronnes.

1
2
3
Data['EN_enc'] = Tokenizer.texts_to_sequences(Data['EN'])
Data['FR_enc'] = Tokenizer.texts_to_sequences(Data['FR'])
print( data[['EN', 'EN_enc', 'FR', 'FR_enc']].sample(10).to_string() )  # Affichage d'un echantillon
                        EN                                    FR                                                         EN_enc                                                                                                                  FR_enc
5842    \tLet me see it.\n             \tLaissez-moi les voir.\n            [5, 14, 2, 7, 1, 15, 2, 1, 3, 2, 2, 1, 4, 7, 10, 6]                                           [5, 14, 8, 4, 3, 3, 2, 33, 32, 15, 9, 4, 1, 14, 2, 3, 1, 21, 9, 4, 13, 10, 6]
9901  \tHe is a student.\n                \tC'est un étudiant.\n    [5, 19, 2, 1, 4, 3, 1, 8, 1, 3, 7, 12, 16, 2, 11, 7, 10, 6]                                                   [5, 17, 18, 2, 3, 7, 1, 12, 11, 1, 25, 7, 12, 16, 4, 8, 11, 7, 10, 6]
268          \tHumor me.\n                    \tFais-moi rire.\n                        [5, 19, 12, 15, 9, 13, 1, 15, 2, 10, 6]                                                                  [5, 27, 8, 4, 3, 32, 15, 9, 4, 1, 13, 4, 13, 2, 10, 6]
4594    \tAre you happy?\n                  \tEs-tu contente ?\n       [5, 8, 13, 2, 1, 22, 9, 12, 1, 19, 8, 20, 20, 22, 28, 6]                                                            [5, 2, 3, 32, 7, 12, 1, 17, 9, 11, 7, 2, 11, 7, 2, 1, 28, 6]
1401       \tIt's a fad.\n       \tC'est un phénomène de mode.\n                    [5, 4, 7, 18, 3, 1, 8, 1, 27, 8, 16, 10, 6]                  [5, 17, 18, 2, 3, 7, 1, 12, 11, 1, 20, 19, 25, 11, 9, 15, 40, 11, 2, 1, 16, 2, 1, 15, 9, 16, 2, 10, 6]
4307     \tWe overslept.\n  \tNous n'entendîmes pas le réveil.\n            [5, 26, 2, 1, 9, 21, 2, 13, 3, 14, 2, 20, 7, 10, 6]  [5, 11, 9, 12, 3, 1, 11, 18, 2, 11, 7, 2, 11, 16, 45, 15, 2, 3, 1, 20, 8, 3, 1, 14, 2, 1, 13, 25, 21, 2, 4, 14, 10, 6]
6639    \tYou're a liar.\n           \tVous êtes une menteuse.\n        [5, 22, 9, 12, 18, 13, 2, 1, 8, 1, 14, 4, 8, 13, 10, 6]                                    [5, 21, 9, 12, 3, 1, 36, 7, 2, 3, 1, 12, 11, 2, 1, 15, 2, 11, 7, 2, 12, 3, 2, 10, 6]
9070   \tWhat if I fail?\n   \tQue se passe-t-il si j'échoue ?\n      [5, 26, 19, 8, 7, 1, 4, 27, 1, 4, 1, 27, 8, 4, 14, 28, 6]       [5, 34, 12, 2, 1, 3, 2, 1, 20, 8, 3, 3, 2, 32, 7, 32, 4, 14, 1, 3, 4, 1, 23, 18, 25, 17, 19, 9, 12, 2, 38, 28, 6]
7702   \tI said get out.\n         \tJe vous ai dit de sortir.\n        [5, 4, 1, 3, 8, 4, 16, 1, 24, 2, 7, 1, 9, 12, 7, 10, 6]                               [5, 23, 2, 1, 21, 9, 12, 3, 1, 8, 4, 1, 16, 4, 7, 1, 16, 2, 1, 3, 9, 13, 7, 4, 13, 10, 6]
9556  \tClose your eyes.\n                   \tFerme tes yeux.\n  [5, 17, 14, 9, 3, 2, 1, 22, 9, 12, 13, 1, 2, 22, 2, 3, 10, 6]                                                              [5, 27, 2, 13, 15, 2, 1, 7, 2, 3, 1, 22, 2, 12, 35, 10, 6]

Ainsi les colonnes ‘EN_enc’ et ‘FR_enc’ vont constituer les données à partir duquel le réseau va apprendre. À noter que la méthode texts_to_sequences prend en paramètre un liste de phrases et non pas une phrase. Si l’on souhaite encoder une seule phrase, alors il faudra la mettre dans une liste à un seul élément.

Les réseaux récurrents

Qu’est-ce qu’un réseau récurrent ? C’est un réseau qui utilise des opérations qu’on appelle cellules récursives, qui ont la propriété de s’alimenter elles-mêmes via un vecteur d’état. Elles ont été inventés pour traiter des séries temporelles c’est-à dire des données (x_1, x_2, x_3, \dots, x_t) de longueur variable qui arrivent dans un ordre séquentiel.

Cellule LSTM

La cellule récursive la plus populaire est le Long Short-Term Memory (abgrégé en LSTM), dont le fonctionnement est le suivant. Une cellule LSTM prend en entrée un vecteur x et deux vecteurs c et h, appelé états de la cellule, et renvoie en sortie un vecteur y et deux vecteur c' et h'. Les vecteurs y, c, h, c' et h' sont tous de même taille, que l’on appelle la dimension de la cellule. On résume le calcul et le fonctionnement d’une cellule LSTM par le schémas suivant, sachant qu’il n’est important de connaître ces détails pour la suite.

		

Rendered by QuickLaTeX.com

Fig. 1 : Schémas d’implémentation d’une cellule LSTM.

Quelques remarques sur cette implémentation. D’abord chaque opération Dense transforme le vecteur de concaténation en un vecteur de taille égale à la dimension de la cellule LSTM. Ensuite les poids de la cellule qui doivent être appris par rétropropagation du gradient sont les poids des quatres opérations Dense qui incluent une matrice de multiplication et une matrice de biais.

Maintenant nous allons voir comment une cellule LSTM s’utilise de manière récursive. Si on lui donne en entrée une suite de vecteurs (x_1, x_2, \dots, x_T) de longueur T, alors la cellule LSTM renvoie une suite de vecteurs (y_1, y_2, \dots, y_T) de même longueur construite de la manière suivante :

  1. Il initialise les vecteurs h_0 et c_0 à zéros. La taille de ces vecteurs est égal à la dimension de la cellule.
  2. Pour tout instant t allant de 1 à T, la cellule prend en entrée x_t, h_{t-1}, c_{t-1} et on définit y_t, h_t et c_t comme ses valeurs de sortie.
		

Rendered by QuickLaTeX.com

Fig. 2 : Schémas d’alimentation d’une cellule LSTM.

Ainsi les vecteurs d’états c_t et h_t sont envoyés par la cellule à elle-même pour être mis-à-jour à chaque instant t. Tout ce qu’il faudra retenir d’une cellule LSTM, c’est qu’à partir d’une suite de vecteurs (x_1, x_2, \dots, x_T) elle fournit une suite de vecteurs (y_1, y_2, \dots, y_T) ainsi que deux vecteurs c et h, tous de taille égale à la dimension de la cellule.

L’architecture du modèle

La feuille de route pour entraîner un modèle et faire une prédiction est très particulière. Les modèles prédictifs classiques prennent une valeur en entrée et renvoient une valeur en sortie. C’est par exemple le cas d’un réseau de neuronnes convolutif (aussi appelé CNN pour Convolutionnal Neural Network) qui fait la classification d’image.

Dans le cas de la traduction, on aurait pu s’attendre à avoir une architecture de réseau qui prend en entrée une phrase anglaise sous forme d’un vecteur caractères encodés et renvoie en sortie la traduction française toutjours sous forme de vecteur. Cependant on ne peut malheureusement pas créer une telle architecture, en tout cas pas l’aide des cellules LSTM. Car la raison est qu’une cellule LSTM fonctionne en prennant entrée une suite (x_1, x_2, \dots, x_T) et en renvoyant une suite (y_1, y_2, \dots, y_T) qui vérifie les conditions suivantes :

  • La suite (y_t)_{t = 0 \dots T} est de même longeur que la suite (x_t)_{t = 0 \dots T}.
  • Pour tout t_0>0, la valeur y_{t_0} ne dépend que des valeurs x_t pour t \leq t_0, c’est-à-dire que des valeurs aux instants passés et non pas aux instants futures.

Or une phrase traduite n’a pas la même longueur que la phrase originale. De plus à cause des différences de règles synthaxiques entre les langues, les mots dans la phrase traduite dépendent de la globalité de la phrase originale.

Le modèle va en fait comporter deux réseaux de neurones. Le premier, qui s’appelle encodeur, se charge de prendre en entrée la phrase anglaise et de renvoyer un vecteur. Ce vecteur est abstrait pour l’utilisateur et représente en quelque sorte « une compression » de la phrase. Et le deuxième réseau appelé décodeur prend en entrée un vecteur dit d’état, de même dimension que la sortie de l’encodeur, ainsi qu’un caractère de la phrase traduite et renvoie en sortie un vecteur d’état ainsi qu’un caractère.

		

Rendered by QuickLaTeX.com

Fig. 3 : Structure de l’encodeur et du décodeur.

Le fonctionnement de cette architecture est comme suit. D’abords on commence par alimenter l’entrée de l’encodeur. Ensuite le vecteur de sortie de l’encodeur va passer en entrée du décodeur. Enfin le décodeur va fonctionner de manière récursive, c’est-à-dire qu’il va s’alimenter lui-même. Son rôle est de prédire le caractère qui suit le caractère en entrée.

Nous allons dans la suite décrire la phase d’entrainement et la phase de prédiction (ou inférence). Cependant contrairement aux modèles classiques, ces deux phases diffèrent fondamentalement par le fait que les valeurs en entrée et en sortie de sont pas les mêmes.

Entraînement du modèle

À partir de l’encodeur et du décodeur, on fabrique un troisième modèle, le modèle d’entraînement. Étant données une phrase anglaise formée de jetons (x_1, x_2, \dots, x_n) et sa traduction (t_1, t_2, \dots, t_m).

  1. D’abord on donne la liste (x_1, x_2, \dots, x_n) en entrée de l’encodeur qui va retourner un vecteur, disons V_0.
  2. Ensuite on donne en entrée du décodeur le vecteur V_0 et récursivement la liste (t_1, t_2, \dots, t_{m-1}) de la même manière qu’une cellule LSTM. Le décodeur retourne alors un vecteur de même longueur, disons (\hat{y}_1, \hat{y}_2, \dots, \hat{y}_{m-1})
  3. Enfin on applique la rétropropagation du gradient en faisant correspondre (\hat{y}_1, \hat{y}_2, \dots, \hat{y}_{m-1}) à la cible qui est (t_2, t_3, \dots, t_m).
		

Rendered by QuickLaTeX.com

Fig. 4 : Schémas du modèle d’entraînement.

Par exemple si nous voulons entraîner le modèle sur la phrase « hello » traduite par « salut », et si nous notons \langle \mathrm{Start} \rangle et \langle \mathrm{End} \rangle les jetons de début et de fin. alors nous aurons :

  • (x_1, x_2,\dots, x_n) = ( \langle \mathrm{Start} \rangle, \text{h}, \text{e}, \text{l}, \text{l}, \text{o}, \langle \mathrm{End} \rangle).
  • (t_1, t_2,\dots, t_{m}) = ( \langle \mathrm{Start} \rangle, \text{s}, \text{a}, \text{l}, \text{u}, \text{t}, \langle \mathrm{End} \rangle).
  • (t_1, t_2,\dots, t_{m-1}) = ( \langle \mathrm{Start} \rangle, \text{s}, \text{a}, \text{l}, \text{u}, \text{t}).
  • (t_2, t_2,\dots, t_{m}) = (\text{s}, \text{a}, \text{l}, \text{u}, \text{t}, \langle \mathrm{End} \rangle).

Inférence sur modèle

Une fois que l’encodeur et le décodeur ont été entrainés via le modèle d’entraînement, faire une prédiction (ou inférence) consiste à donner en entrée une phrase anglaise et à avoir en sortie sa traduction française. Étant donnée une phrase anglaise formée de jetons (x_1, x_2, \dots, x_n) pour la faire traduire par le modèle :

  1. Premièrement on passe (x_1, x_2, \dots, x_n) en entrée de l’encodeur. En sortie on renvoie un vecteur V_0.
  2. Deuxièmement la prédiction (\hat{y}_1, \hat{y}_2, \dots, \hat{y}_m) est définie récursivement de la manière suivante.
    1. Initialiser t=0 et \hat{y}_0 prend la valeur du jeton \langle \mathrm{Start} \rangle.
    2. Puis faire passer V_{t} et \hat{y}_t en entrée du décodeur. Le décodeur renvoie alors une prédiction \hat{y}_{t+1} et un vecteur V_{t+1}.
    3. Enfin incrémenter t et recommencer l’étape précédente jusqu’à que \hat{y}_{t} soit égal au jeton \langle \mathrm{end} \rangle (ou que t soit trop grand pour s’assuser que l’algorithme termine).
		

Rendered by QuickLaTeX.com

Fig. 5 : Schémas d’inférence.

Ce schémas d’inférence explique la raison d’avoir les jetons \langle \mathrm{Start} \rangle et \langle \mathrm{End} \rangle. En effet le jeton \langle \mathrm{Start} \rangle sert de premier entrée au décodeur et le jeton \langle \mathrm{End} \rangle sert à savoir quand il faut arrêter d’alimenter le décodeur.

Implémentation du modèle

Nous allons donner une implémentation simple d’une telle architecture avec la librairie keras.

Codage de l’encodeur, décodeur et modèle d’entrainement

Nous utiliserons l’unité de calcul LSTM implémentée par la classe keras.layers.LSTM dont nous commençons par expliquer le fonctionnement. Comme indiqué dans l’introduction le lecteur est supposé connaître les notions de tenseur, batch, feature et de l’utilisation d’un keras.layers (instanciation et appel).

La classe keras.layers.LSTM s’instencie avec des paramètres dont les principaux que nous allons utilisés sont :

  • units (entier) : indique la dimension de la cellule.
  • return_sequences (booléen, False par défaut) : True signifie qu’en sortie on récupère toute la suite (y_1, y_2, \dots, y_T) sur la figure Fig 2, et False signifie qu’on ne récupère que le dernier élément y_T de la suite.
  • return_state (booléen, False par défaut) : True signifie qu’en plus à la sortie en retourne les deux états h_T et c_T de la figure Fig. 2.

Une fois qu’une cellule keras.layers.LSTM a été instancié, il faut l’appeler sur un tenseur. Ce tenseur doit être de format [batch, timesteps, feature], avec en général batch et timesteps de dimension None (c’est-à-dire non-déterminée).

Si return_sequences = True, alors l’appel crée un nouveau tenseur de format [batch, timesteps, feature] et si return_sequences = False, alors le tenseur crée est de format [batch, feature]. Dans les deux cas la dimension de feature est égale à units, la dimension de la cellule.

Si return_state = True, alors l’appel crée en plus deux tenseurs en plus h et c chacun de forme [batch, feature], avec feature de dimension égale à units.

Lors de l’appel on peut aussi passer en paramètre initial_state la liste formée des deux états h et c en entrée, qui sont des tenseurs de format [batch, feature], à passer en entrée de la cellule. Et si ce paramètre n’est pas renseigné, alors par défaut ils sont égaux au tenseur nulle.

Les détails d’implémentation de l’encodeur et du décodeur, ainsi que le modèle d’entrainement sont donnés par les schémas suivants. Ici, Num indique le nombre de jetons différents dans le Tokenizer.

		

Rendered by QuickLaTeX.com

Fig. 6a : Implémentation de l’encodeur.
		

Rendered by QuickLaTeX.com

Fig. 6b : Implémentation du décodeur.
		

Rendered by QuickLaTeX.com

Fig. 6c : Implémentation du modèle d’entraînement.

Quelques précisions sur l’implémentation.

  • L’encodeur prend entrée Input, un tenseur de format [batch, timestep]. Les valeurs de ce tenseur sont des entiers positifs representant le code des jetons (ce n’est pas l’encodage one-hot que l’on met en entrée). En effet l’encodage one-hot est géré par l’unité de calcul keras.layers.Embedding qui se charge de convertir Input en sa version one-hot puis d’appliquer une transformation linéaire.
  • De même l’entrée du décodeur est un tenseur de format [batch, timestep] d’entiers positifs et son encodage one-hot est géré par un keras.layers.Embedding.
  • Par contre la sortie Output du décodeur est un tenseur de format [batch, timestep, feature] où feature est de dimension le nombre de jeton du Tokenizer noté Num. La valeur prédite en retour est donc une version one-hot encoded. Ainsi lors de la phase d’inférence il faudra donc convertir la valeur de sortie en utilisant argmax.
  • L’unité de calcul Dense Softmax a deux rôles : avoir une dimension égale à Num en sortie, et utiliser l’activation Softmax pour avoir des probabilités (c’est-à-dire des valeurs positives de somme 1).
  • Le choix des dimensions de Emdedding (32) et de LSTM (1024) est arbitraire et doivent être calibrées par le lecteur pour des résultats optimales.

Note : La classe keras.layers.LSTM admet à la construction un paramètre activation. Cependant contrairement à ce que l’on peut penser cette activation n’est pas appliquée à la sortie de la cellule. En effet cet attribut vaut par défaut \mathrm{tanh} et correspond aux deux activations \mathrm{th} dur la figure Fig. 1 (l’autre activation \sigma est donné par le paramètre recurrent_activation). Ainsi s’il l’on crée un keras.layers.LSTM avec activation = ‘softmax’, le vecteur de sortie n’est pas de somme 1, car c’est le produit d’un vecteur activé avec Softmax par un vecteur activé avec Sigmoid.

Voici l’implémentation de l’encodeur, du décodeur et du modèle d’entrainement.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import keras

num_tokens = len(Tokenizer.word_index) + 1 # +1 car l'indice des jetons commence à 1
embedding_units = 32
lstm_units = 1024

def createEncoder():
    inputs = keras.layers.Input(shape=(None,))
    embed_layer = keras.layers.Embedding(input_dim=num_tokens, output_dim=embedding_units)
    embed = embed_layer(inputs)

    lstm_layer = keras.layers.LSTM(units=lstm_units, return_sequences=False, return_state=True)
    _, h, c = lstm_layer(embed)

    return keras.models.Model(inputs, [h, c])

def createDecoder():
    inputs = keras.layers.Input(shape=(None,))
    embed_layer = keras.layers.Embedding(input_dim=num_tokens, output_dim=embedding_units)
    embed = embed_layer(inputs)
    input_h = keras.layers.Input(shape=(lstm_units,))
    input_c = keras.layers.Input(shape=(lstm_units,))
       
    lstm_layer = keras.layers.LSTM(units=lstm_units, return_sequences=True, return_state=True)
    lstm_out, h , c = lstm_layer(embed, initial_state=[input_h, input_c])

    dense_layer = keras.layers.Dense(num_tokens, activation='softmax')
    out = dense_layer(lstm_out)

    return keras.models.Model([inputs, input_h, input_c], [out, h, c])

def createTrainingModel(Encoder, Decoder):
    out, h, c = Decoder( (Decoder.input[0], Encoder.output[0], Encoder.output[1]) )  # Note : Decoder.input[0] = inputs, Encoder.output[0] = h, Encoder.output[1] = c

    model = keras.models.Model([Encoder.input, Decoder.input[0]], out)
    model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')

    return model


Encoder = createEncoder()
Decoder = createDecoder()
TrainingModel = createTrainingModel(Encoder, Decoder)

Concernant la métrique loss=’sparse_categorical_crossentropy’, le mot sparse signifie que lorsqu’on appelle la méthode TrainingModel.fit la cible devra être un tenseur de format [batch, timestep] de coefficient entiers positifs (et non pas sa version one-hot qui est un tenseur de format [batch, timestep, feature]).

Phase d’entrainement

La phase d’entraînement d’un tel modèle est un peu plus délicate qu’un réseau de neuronnes classique. Effectivement dans un modèle classique, il suffit d’appeler la méthode .fit sur les données, qui se charge d’appliquer la méthode du gradient par la méthode mini-batch. Mais cela ne marche que si toutes les entrées des données ont le même format. Or ici ce n’est pas le cas, car la dimension timestep est différente d’une donnée à l’autre. Cependant il y a deux façons pour régler ce problème :

  1. Ne pas utiliser la méthode mini-batch et appeler la méthode .fit sur chaque entrée des données.
  2. Compléter les entrées par des jetons \langle \mathrm{End} \rangle pour qu’elles aient toutes la même dimension timestep.

La première est la plus simple, mais elle a comme gros inconvenient d’être déraisonnablement lente. La deuxième méthode est beaucoup plus rapide, mais lors de la phase d’inférence il faut aussi compléter les données avec les jetons \langle \mathrm{End} \rangle, car le modèle n’aura été appris que sur des données d’une taille fixée. C’est cette deuxième qu’il faut privilégier.

Bien qu’il serait facile de d’implémenter la deuxième méthode à la main, Keras admet une fonction qui permet de faire cette opération, à savoir keras.preprocessing.sequence.pad_sequences. Cette fonction admet comme paramètre

  • sequences (liste de séquences) : liste formée des séquence à compléter par value.
  • maxlen (entier ou None (par défaut)) : taille maximale. Si None, alors maxlen est la taille de la plus longue séquence apparaissant dans sequences
  • dtype (par défaut ‘int32’) : type de données.
  • padding (vaut ‘pre’ (par défaut) ou ‘post’) : indique si on complète au début ou à la fin.
  • truncating (vaut ‘pre’ (par défaut) ou ‘post’) : indique si on tronque les séquences de longueur supérieure à maxlen au début ou à la fin.
  • value (vaut par défaut 0) : valeur par laquelle compléter.

Cette fonction retourne un numpy.array de format [batch, timestep] avec batch de dimension len(sequences) et timestep de dimension maxlen. En conséquence le code pour entraîner TrainingModel se présente alors de la manière suivante.

1
2
3
4
5
6
7
input_encoder = keras.preprocessing.sequence.pad_sequences(Data["EN_enc"], maxlen=None, dtype='int32', padding='post', value=Tokenizer.word_index['\n'])
input_decoder = keras.preprocessing.sequence.pad_sequences(Data["FR_enc"], maxlen=None, dtype='int32', padding='post', value=Tokenizer.word_index['\n'])

target = numpy.roll(input_decoder, shift=-1)  # Décalage vers la gauche
target[:,-1] = Tokenizer.word_index['\n']     # Correction sur le dernier jeton

TrainingModel.fit([input_encoder, input_decoder], target, epochs=100, verbose = 2)

Le lecteur pourra éventuellement faire une séparation avec des données de validation et/ou utiliser des callbacks.

Phase d’inférence

Encore une fois cela ne se passe pas comme pour les modèles classiques où il suffit d’appeler la méthode .predict sur le modèle. En effet la fonction pour faire l’inférence donnée pas la figure Fig. 5 doit être malheureusement codée à la main. La fonction Translate suivante prend entrée une phrase anglaise sous forme de string et renvoie la prédiction par le modèle sous forme de string.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
MAX_LENGTH = input_encoder.shape[1] ## dimension timestep

def Translate(phrase):
    ## Ajout des jetons de début et de fin
    phrase = '\t' + phrase + '\n'  

    ## Encodage des jetons
    encoder_input_data = Tokenizer.texts_to_sequences([phrase])  # format [batch, timestep] avec batch=1

    ## ajustement de la dimension timestep
    encoder_input_data = keras.preprocessing.sequence.pad_sequences(encoder_input_data, maxlen=MAX_LENGTH,
                          dtype='int32', padding='post', truncating='post', value=tokenizer_EN.word_index['\n'])  

    ## Récupération de la sortie de l'encodeur
    h, c = Encoder.predict(encoder_input_data)  # format [batch, feature] avec batch=1 et feature=1024

    char = Tokenizer.word_index['\t'] ## Jeton <START>
    result = list()
    result.append(char)
    while char != tokenizer_FR.word_index['\n'] and len(result) <1000: ## Tant que char != <END> ou result pas trop grand
        input_dec = np.array([[char]])  ## input_dec : format [batch, timestep] avec batch=1, timestep=1,
        out ,h ,c = Decoder.predict([input_dec ,h ,c])  ## out : format [batch, timestep, feature] avec batch=1, timestep=1, feature=num_tokens
        char = np.argmax(out[0,-1,:])
        result.append(char)

    return ''.join([Tokenizer.index_word[x] for x in result])  ## Conversion des jetons encodés en caractères

Ainsi se termine ce tutoriel sur comment créer un réseau de neurones pour faire de la traduction. Précisons finalement que le modèle est extrêmement simple car le but était de présenter le principe d’utilisation des réseaux récurrents ainsi que l’architecture avec encodeur et décodeur.

Enfin vous pouvez trouver les codes Python de cet article ici