Projet Générateur automatique de texte
Magique, Le Chat de Mistral AI ? A partir d’un cours texte de départ ( le « prompt » ), « il » est capable de créer un discours très structuré et cohérent, et ce sur n’importe quel sujet !
Aucune magie, évidemment, contrairement à ce dont beaucoup de gens ( dont vous ne faites pas partie bien entendu ) sont persuadés…
Le Chat est un agent conversationnel destiné à de la génération automatique de texte ( il existe également des générateurs d’images, comme Dall-E ).
Il est basé sur un grand modèle de langage ( en anglais : LLM = large Model Language ), c’est à dire une modélisation mathématique très poussée du
langage humain, modélisation issue de l’analyse d’un très grand corpus ( = ensemble ) de données, qui lui a permis de déterminer les « règles » que suit le langage humain
pour élaborer des phrases, des textes, etc.…
Pour faire simple, Le Chat, comme la majorité des modèles de langage, ne fait qu’une seule chose ( mais il la fait bien ) : déterminer quel sera le mot le plus probable qui suivra un autre dans un texte donné.
Comment une machine arrive-t-elle à faire cela ?
Génération automatique de texte
Le problème
Il y a une centaine d’années, le mouvement artistique dit des Surréalistes a inventé un « jeu » consistant à plusieurs personnes à
écrire une phrase, la première personne proposant un mot, puis la suivante un verbe, la suivante un adjectif, etc., et ce sans que chaque personne ne connaisse ce que les autres ont proposé au
préalable.
Ce jeu a été par la suite appelé le Cadavre Exquis, car la première phrase qui avait été « générée » selon ce principe était
: "Le cadavre - exquis - boira - le vin - nouveau."
Bien que du fait des règles du jeu, les phrases générées selon ce principe sont grammaticalement correctes, elles n’ont que très peu de sens au final...
La raison en est que chaque participant ne connaît pas le contexte dans lequel le mot qu’il doit proposer doit être, c’est à dire l’ensemble des mots qui vont précéder le sien
dans la phrase finale ; or, c’est ce contexte qui nous permet de saisir le sens d’une phrase.
Par exemple, si on commence à écrire les mots :
Je suis allé bronzer au ...
...on conçoit tout de suite que la suite sera probablement :
Je suis allé bronzer au soleil.
Écrire une phrase cohérente et sensée suppose donc, en plus de respecter les règles de grammaire, de connaître le contexte dans lequel la phrase se tient, et c’est tout le problème pour une machine de s'approprier ces concepts fondamentalement humains !
Analyse de contexte
Le Chat est basé sur des outils mathématiques très pointus pour analyser le contexte d’un texte, et il dispose d’une « fenêtre de contexte », c’est à dire le nombre de « mots » ( ce sont en fait
des suites de caractères de longueur variable appelés tokens en anglais ) sur lequel son analyse de contexte se fait, de plusieurs milliers de "mots" !
Bien entendu, il y a un code très technique ( utilisation de réseaux de neurones et de concepts informatiques très avancés ) à écrire, ainsi qu’un « entraînement » très lourd ( et très
énergivore ! ) du modèle sur un très large corpus avant qu'il soit opérationnel.
Or, il existe des modèles beaucoup plus simples ( mais également moins performants..), dit modèles de langages statistiques, qui sont basés sur la constatation que, pour bâtir une phrase sensée, il suffit de ne regarder que les quelques mots précédents du contexte ( 1, 2 ou trois, 5 au maximum ) pour déterminer les mots qui vont suivre alors.
Dans l’exemple précédent, le début de la phrase n’est pas indispensable pour saisir le contexte, il suffit en effet d’écrire :
"bronzer au ..."
...pour comprendre que la suite de la phrase sera :
"bronzer au soleil."
Cette constatation se généralise à une suite donnée de n-gramme; un n-gramme peut être un ensemble de n mots successifs dans un texte ( comme ci-dessus ), ou un ensemble de n caractères, comme dans l'approche que nous allons adopter ici.
Par exemple, pour la suite du n-gramme de 3 caractères ( = trigramme ) :
'bro'
... une personne proposera probablement :
'broche', 'bronche', 'broncher', 'bronzer', etc...
Dans un contexte de texte relatif aux vacances, le mot "candidat" serait alors probablement "bronzer"; mais comment faire comprendre ça à une machine ? Il va falloir lui apprendre !
Une méthode (simple) pour apprendre à une machine à écrire...
Cette activité porte sur une méthode destinée à faire de la génération automatique de texte, en apprenant à une machine à prédire le n-gramme de caractères le plus susceptible d'en suivre un autre dans le texte à générer.
On considérera des n-grammes formés de n caractères contigus ( plus simple à coder qu'avec des n-grammes de mots ).
Principe
- on « tokenise » un corpus de texte(s) destiné à l’apprentissage, c’est à dire qu’on le fragmente en n-grammes ( signes de ponctuation inclus ) individuels de taille n, valeur que l'on pourra faire varier afin d'observer l'influence de ce paramètre sur les performances du modèle.
- on établit la liste des paires de n-grammes consécutifs qui apparaissent dans le corpus
- on détermine alors la fréquence ( c'est à dire le pourcentage de fois ) à laquelle un n-gramme apparaît après un autre dans le corpus.
Prenons pour exemple le texte suivant :
Pour n = 2, les 2-grammes ( = digrammes ) de cette chaîne sont ( les espaces font partie des n-grammes ) :
Parmi tous les "contextes" possibles dans le texte, intéressons-nous à celui du digramme mu ( sans tenir compte de l'accentuation ) :
On constate que l'on trouve :
- 1 fois la paire
(mu, rs) - 1 fois la paire
(mu, rm) - 2 fois la paire
(mu, re)
→ le contexte mu apparaît donc 4 fois dans le corpus; on peut alors déduire la fréquence avec laquelle chacun des digramme suivant apparaît ( la somme des fréquences
est égale à 1 ) :
- 1/4 = 0.25 pour
rs - 1/4 = 0.25 pour
rm - 1/2 = 0.50 pour
re
Conséquence : on "apprend" alors à la machine que, dans un texte quelconque, le digramme le plus probable qui suivra le digramme mu sera celui dont la
fréquence d'apparition dans le corpus d'apprentissage est la plus grande, c'est à dire ici le digramme re.
Pour bâtir un texte complet, le générateur de texte, après la phase d’analyse du corpus, pourra ainsi choisir, n-gramme après n-gramme, ( donc à chaque changement de contexte ) un « candidat » probable
à ajouter à la suite des mots d’une phrase ( le début du texte, le "prompt", étant fourni par l'utilisateur ).
On verra cependant que, pour que le texte paraisse vraiment original et différent à chaque exécution, il ne faut pas choisir forcément le candidat le plus probable, mais plutôt le « piocher »
aléatoirement dans une liste de candidats possibles.
Le concept utilisé s'appelle une chaîne de Markov : c'est une suite d'éléments ( ici, des n-grammes ), dans laquelle la détermination d'un
élément donné ne nécessite pas de considérer l'ensemble de la chaîne, mais uniquement l'élément actuel , en utilisant la probabilité de passer de cet élément au suivant.
Ce principe pourrait être de la même manière appliqué à une suite de notes de musique, dans le but de produire des morceaux
aléatoires s'inspirant de morceaux connus !
C'est une méthode simple ( et donc limitée...), mais facile à coder, et surtout, qui ne nécessite par un énorme corpus d'apprentissage, du moins lorsque n reste petit.
Limitations du modèle
On s'en doute, ce modèle souffre de plusieurs limitations :
- tous les n-grammes possibles ne se retrouveront forcément pas dans le corpus, d'où le "blocage" du modèle en cas de rencontre d'un n-gramme inconnu.
- on l'a dit, pour un n-gramme donné, on ne considère le contexte que de son n-gramme précédent, ce qui est faible si on veut obtenir un texte relativement sensé.
On pourrait augmenter le nombre de n-grammes précédents à considérer comme contexte, donc augmenter ce que l'on appelle l'ordre de la chaîne de Markov, mais on constate qu'à partir d'un ordre de 3, le texte généré commence à beaucoup trop ressembler au corpus d'apprentissage ( "plagiat" )... - On voit que le choix du corpus aura forcément un impact sur les résultats du modèle une fois celui-ci entraîné, d’où les biais qui peuvent apparaître ( mais c'est le cas avec tous les modèles de langage, même Le Chat ) au cas où le corpus d’entraînement choisi ait été trop petit, pas assez diversifié, ou ait contenu des informations erronées...
Implémentation du modèle de langage
Le travail sera fragmenté en différentes fonctions, que vous devrez compléter ou dont vous devrez écrire complètement le code.
Extraction des n-grammes d'un texte
Compléter le code de la fonction extraire_ngrammes ci-dessous, qui :
- prend en paramètre une chaîne texte contenant un texte ( qui sera issu du corpus d'entraînement ), et un entier n codant la taille des n-grammes utilisé.
- renvoie la liste des n-grammes consécutifs du texte.
Par exemple, avec texte = "aabbababaaabcaabca", et n = 2, la fonction doit renvoyer la liste :
['aa', 'bb', 'ab', 'ab', 'aa', 'ab', 'ca', 'ab', 'ca']
Analyse du contexte de chaque ngramme
Le gros du travail : à partir de la liste des n-grammes, établir dans quel "contexte" chacun se trouve, c'est à dire, pour un n-gramme donné dans la liste ( le "suivant" ), déterminer celui qui le précède ( le "précédent" ), et combien de fois la suite (précédent, suivant) apparaît dans le corpus.
Ce comptage permettra par la suite de calculer les fréquences avec lesquelles un n-gramme donné en suit un autre.
Pour réaliser ces calculs, le code devra remplir deux dictionnaires :
- un dictionnaire contexte, dont :
- les clés seront les différents n-grammes du corpus,
- les valeurs, la liste des n-grammes qui suivent les clés dans le texte.
Ce dictionnaire permettra :contexte = { 'aa': ['bb', 'ab'], 'bb': ['ab'], 'ab': ['ab', 'aa', 'ca', 'ca'], 'ca': ['ab'] }- de savoir combien de fois un n-gramme donné apparaît dans le corpus ( donc combien de fois un "contexte" donné apparaît ),
- de savoir quels n-grammes sont "candidats" à suivre un n-gramme donné
- un dictionnaire compteur, dont :
- les clés seront les tuples (précédent, suivant) possibles dans le corpus,
- les valeurs, le nombre de fois où la suite (précédent, suivant) apparaît dans le corpus.
Ce dictionnaire permettra de déterminer le nombre de fois où une suite de deux n-grammes donnés (précédent, suivant) apparaît dans le corpus.compteur = { ('aa', 'bb'): 1, ('bb', 'ab'): 1, ('ab', 'ab'): 1, ('ab', 'aa'): 1, ('aa', 'ab'): 1, ('ab', 'ca'): 2, ('ca', 'ab'): 1 }
Bien entendu, ces deux dictionnaires seront bien plus complets en réalité !
On donne ci-dessous l'algorithme à suivre pour remplir ces deux dictionnaires :
contexte ← dictionnaire vide
compteur ← dictionnaire vide
pour chaque n-gramme dans la liste:
précédent ← n-gramme précédent ( ou n-gramme courant )
suivant ← n-gramme courant ( ou n-gramme suivant )
si précédent n'est pas encore dans contexte:
on crée la clé précédent avec comme valeur la liste vide
on ajoute suivant à la liste associée à la clé précédent
si le tuple (précédent, suivant) n'est pas encore dans compteur:
on y créé une clé (précédent, suivant) avec comme valeur 0
on incrémente la valeur associée à la clé (précédent, suivant)
renvoi de contexte et de compteur
"
Écrire une fonction analyse_contexte qui :
- prend comme paramètre la liste des n-grammes du corpus
- renvoi les deux dictionnaires contexte et compteur une fois remplis.
Calcul des fréquences d'apparition
Le but est maintenant, à partir des dictionnaires contexte et compteur, de calculer la fréquence d'apparition de chaque suite de deux n-grammes.
Cette fréquence est calculée à partir de la formule :
(précédent, suivant)Nbre d'apparition de précédent- réfléchir à comment, à partir des données contenues dans les dictionnaires contexte et compteur, calculer la fréquence d'apparition d'un n-gramme donné après un autre dans le corpus.
- écrire alors une fonction
calcul_frequences, qui :- prend comme paramètres les dictionnaires contexte et compteur
- renvoie un dictionnaire frequences dont :
- les clés sont les différents n-grammes du corpus,
- les valeurs sont les listes des différents n-grammes qui peuvent suivre la clé avec leur fréquence respective, sous la forme d'un tuple (suivant, fréquence).
frequences = { 'aa': [('bb', 0.5), ('ab', 0.5)], 'bb': [('ab', 1.0)], 'ab': [('ab', 0.25), ('aa', 0.25), ('ca', 0.5)], 'ca': [('ab', 1.0)] }
Choix d'un n-gramme "candidat" dans un contexte donné
On a donc maintenant un moyen de connaître les n-grammes candidats possibles à la suite d'un autre n-gramme donné.
Il reste maintenant à choisir dans cette liste un candidat probable à chaque fois que nécessaire; le problème est, comme on l'a dit, que si on choisit toujours le plus fréquent, l'algorithme de génération de texte entrera rapidement dans une "boucle" : le texte généré deviendra répétitif et proposera en boucle les mêmes phrases...
Il faut donc choisir un candidat probable de manière "semi-aléatoire", c'est à dire tenir compte de la probabilité d'apparition des n-grammes, mais avec une marge de hasard...
Voila l'approche que l'on va suivre :
- on trie la liste des candidats par ordre de fréquence décroissante
- on tire au hasard une fréquence entre 0.0 et 1.0
- on parcourt la liste des candidats, en accumulant leurs fréquences dans un compteur
- quand la valeur du compteur a atteint la fréquence tirée au hasard, alors on sélectionne le n-gramme courant du parcours.
Écrire une fonction choisir_mot qui, dans une liste de candidats, choisit un n-gramme selon le principe énoncé ci-dessus.
Pour le tri de la liste, on appliquera un des algorithmes vus en Première, comme par exemple le tri sélection.
Tester la fonction ( par exemple avec la liste des candidats suivant du n-gramme 'ab' ), et vérifier que le plus fréquent "sort" bien le plus souvent, mais pas toujours...
Génération de texte
Tout est en place pour écrire la fonction principale du programme; elle devra :
- ouvrir le fichier texte du corpus et en extraire le texte ( le code est déjà écrit pour cela )
- extraire la liste des n-grammes du texte
- créer les dictionnaires contexte et compteur, puis le dictionnaire fréquences.
- à partir du "prompt" fourni en argument ( ou plus précisément, à partir de son dernier n-gramme qui donnera le contexte "de départ" du texte ), générer N mots qui compléteront le prompt.
Écrire une fonction genere_texte, qui :
- prend comme paramètres une chaîne de caractères ( le "prompt ), un entier n indiquant la taille des n-grammes, et un entier N indiquant le nombre de mots à générer
- renvoie une chaîne de caractères représentant le texte généré
Vous pouvez utiliser ce fichier texte comme corpus d'apprentissage pour le modèle de langage ( qui sera donc capable d'écrire comme Victor Hugo 😲)
Appeler ensuite la fonction, en lui donnant divers valeurs pour n ( de 3 à 10 environ ).
Il était une fois observée. La chute de tout, autour de lui. Une troisième renversa en arrière Blanche au chemin d'Aubervilliers, en quatre mois d'agonie. Beaucoup de choses s'y étaient fermée par une grille dont il est vrai, livide, mais précise dans sa boîte fleurs. Tout autour de Jean Valjean reconnut Javert. --Voilà, grommela-t-il, de quoi fouiller la terr
Conclusion et améliorations
Ok, ok, ce n'est pas Le Chat...quelques pistes d'amélioration possibles :
- comme on l'a dit, travailler sur un ordre supérieur ( 2 ou 3 ), mais sans trop l'augmenter sous peine de "plagiat" du corpus...
- entraîner le modèle sur un corpus plus étendu
- au lieu de travailler sur des n-grammes de caractère, travailler avec des n-grammes de mots
- apprendre les concepts derrière les modèles de langage actuels... 😄