6.14 Génération de texte en LispE avec une grammaire - naver/lispe GitHub Wiki

La génération de texte à l'ancienne

English Version

La méthode que nous allons présenter ici appartient à la vieille école, celle d'avant les GPT3 et autres modèles de langue. Mais parfois, si l'on ne dispose pas d'une dizaine de GPU sous la main et d'un bon téra de disque dur, enfin la config normale d'un informaticien d'aujourd'hui... Ah! Ce n'est pas votre cas... Je ne savais pas... Pardon...

Il existe donc des méthodes assez traditionnelles qui permettent d'arriver à des résultats similaires. Enfin, ça n'écrira pas votre rapport de fin d'année...

Je vais vous en présenter une que j'ai concocté avec le langage qui est développé ici à NLE depuis deux ans: LispE.

La grammaire

Dans le cas d'une génération à l'ancienne, il faut une grammaire de génération.

Par exemple:

  S : NP VP
  PP : PREP NNP
  NP : DET NOUN
  NP : DET NOUN PP
  NP : DET ADJ NOUN
  NNP : DET NLOC
  NNP : DET ADJ NLOC
  VP : VERB NP
  DET : "the"  "a" "this" "that"
  NOUN : "cat"  "dog"  "bat" "man" "woman" "child" "puppy"
  NLOC : "house" "barn" "flat" "city" "country"
  VERB : "eats"  "chases"  "bites" "sees"
  PREP : "of" "from" "in"
  ADJ : "big" "small" "blue" "red" "yellow" "petite"

Il existe aussi une grammaire pour le français, mais il faut avouer qu'elle est un poil compliquée à lire, du fait des règles d'accord en particulier.

Compiler cette chose

Cette grammaire est plutôt simple à lire. On part d'un noeud phrase «S» pour «Sentence», qui est composé d'un groupe nominal et d'un groupe verbal. Les règles qui suivent donnent les différentes formes que chacun de ces groupes peut prendre. Ainsi un groupe nominal: NNP peut se décomposer sous la forme d'un déterminant suivi d'un adjectif et d'un nom à la fin.

La compilation de cette grammaire consiste à créer un gros dictionnaire indexé sur les parties gauches de ces règles:

{
   %ADJ:("big" "small" "blue" "red" "yellow" "petite")
   %DET:("the" "a" "this" "that")
   %NLOC:("house" "barn" "flat" "city" "country")
   %NOUN:("cat" "dog" "bat" "man" "woman" "child" "puppy")
   %PREP:("of" "from" "in")
   %VERB:("eats" "chases" "bites" "sees")
   ADJ:"%ADJ"
   DET:"%DET"
   NLOC:"%NLOC"
   NNP:(("DET" "NLOC") ("DET" "ADJ" "NLOC"))
   NOUN:"%NOUN"
   NP:(
      ("DET" "NOUN")
      ("DET" "NOUN" "PP")
      ("DET" "ADJ" "NOUN")
   )
   PP:(("PREP" "NNP"))
   PREP:"%PREP"
   S:(("NP" "VP"))
   VERB:"%VERB"
   VP:(("VERB" "NP"))
}

Certaines lignes sont des simples copier/coller des règles au-dessus, à l'exception des règles lexicales que l'on fait précéder d'un «%». Le but, c'est d'être capable de différencier entre l'application d'une règle et la génération des mots.

Analyser et générer avec la même grammaire

C'est certainement là qu'est le côté sympa de l'approche qu'on propose ici.

On va se servir de cette grammaire dans les deux sens, ce qui signifie qu'on peut lui donner à manger un bout de phrase et le laisser terminer.

Par exemple si on lui dit: un chat, il pourra alors proposer ses propres continuations.

Notons qu'ici, les continuations tireront des mots au hasard dans les listes de mots. Ce qui pourra donner des phrases complètement ridicules... ou pas.

La première étape

L'utilisateur va donc fournir un début de phrase, mais aussi et c'est fondamental, le symbole initial correspondant à ce qu'il cherche à produire.

Ce symbole est un point d'entrée dans notre grammaire. Nous allons choisir: S.

Autrement dit, nous allons demander au système de produire une phrase.

A la première étape nous avons donc deux listes en parallèle:

  Mots       Catégories
("a "cat")     ("S")

Le remplacement

S est un point d'entrée dans la grammaire dont la valeur est: ("NP" "VP")

On remplace donc la structure au-dessus pour refléter cette possibilité.

   Mots        Catégories
("a "cat")     ("NP" "VP")

La tête de la liste des catégories est maintenant: NP.

Comme il existe plusieurs règles possibles pour NP, nous allons simplement boucler pour trouver celle qui couvre notre liste de mots:

  Mots            Catégories
("a "cat")     ("DET" "Noun" "VP")

Désormais notre tête est DET qui pointe vers un élément lexical. Il nous suffit de vérifier que "un" appartient à la liste associée à "DET".

C'est la cas, nous pouvons alors éliminer des éléments des deux listes:

  Mots       Catégories
("cat")     ("Noun" "VP")

On peut effectuer la même opération pour "Noun", la liste des mots est alors vide.

Mots  Catégories
()     ("VP")

On bascule alors dans le mode génération.

Génération

VP nous retourne une liste ne comprenant qu'un seul élément: ("Verb" "NP")

    Catégories       Générés
  ("Verb" "NP")     ("a" "cat")

Remarquons que «Générés» contient comme valeur initiale les mots provenant de notre bout de phrase.

Verb étant un élément lexical, nous tirons alors un mot au hasard parmi notre liste de verbes:

  Catégories       Générés
   ("NP")     ("a "cat" "chases")

Nous tirons alors une règle au hasard parmi celles associées à NP:

   Catégories                 Générés
("Det" "Adj" "Noun")     ("a "cat" "chases")

Le travail est désormais très simple, il suffira de tirer un déterminant, un adjectif et un nom au hasard dans leur liste respective:

Catégories              Générés
  ()         ("a "cat" "chases" "a" "big" "dog")

Comme la liste des catégories est désormais vide, on s'arrête là et on retourne notre liste de mots générés.

Détail d'implantation en LispE

Si vous jetez un coup d'oeil rapide au code de l'analyseur, vous observerez la présence de deux fonctions: match et generate. Ces fonctions sont basées sur l'utilisation massive de defpat, la programmation par motif (pattern programming) dans LispE.

match

match est utilisé pour vérifier si les mots dans une phrase peuvent être analysés par la grammaire. Les conditions de réussite de match sont doubles:

  • Soit la liste de mots et la liste de catégorie est vide
  • Soit la liste de mots est vide et le système continue en mode génération sur les catégories restantes

; Nous avons consommé tout nos mots et nos catégories
; Pas la peine d'aller plus loin
(defpat match ([] [] consume) 
   (nconcn consume "$$") 
)

; On s'arrête et on génère, la liste de mots est vide
(defpat match ( current_pos  []  consume)   
   (generate current_pos consume)
)

; Nous vérifions la règle associée à la catégorie en tête
; consp vérifie si un objet est une liste. Si ce n'est pas le cas, il s'agit d'une règle lexicale.
; Sinon, on boucle sur les règles possibles. 
(defpat match ( [POS $ current_pos] [w $ sentence] consume)
   (setq rule (key grammar POS))
   (if (consp rule) ; si c'est un groupe de règles, on boucle pour trouver la bonne
      (loop r rule
         (setq  poslst (match (nconcn r current_pos) (cons w sentence) consume))
         (if poslst
            (return poslst) ; on en trouve une on s'arrête
         )
      )
      (if (in (key grammar rule) w) ; sinon c'est une règle lexicale et on vérifie si le mot courant en fait partie
         (match current_pos sentence (nconcn consume w))
      )
   )
)

generate

La génération est donc l'étape ultime. Grâce à la programmation par motif, cette opération se résume à deux fonctions.

; Génération d'un mot
; Nous cherchons donc une règle
; Celle-ci est soit une règle normale (consp) soit une règle lexicale
(defpat generate([POS $ current_pos] tree)
   (setq r (key grammar POS))
   (if (consp r)
      ; ici place en tête les catégories d'une règle tirée au hasard
      (generate (nconcn (random_choice 1 r 30) current_pos) tree) 
      ; ici on rajoute un mot tiré au hasard
      (generate current_pos (nconc tree (random_choice 1 (key grammar r) 30))) 
   )
)  

; Il n'y a plus de catégories disponibles, on place un symbole de fin de séquence pour indiquer que l'on 
; a généré tout ce que l'on pouvait
(defpat generate ([] tree) (nconc tree "%%") )

Conclusion

Pour ceux qui ont déjà eu l'occasion de travailler avec Prolog, cette façon de concevoir un programme doit leur sembler très familière. Pour les autres, cette façon de programmer peut sembler plutôt déconcertante. L'utilisation d'un motif pour distinguer différentes fonctions portant le même nom mais avec des arguments différents s'appelle du «polymorphisme». On retrouve d'ailleurs ce genre de fonctionnement en C++:

    Element* provideString(wstring& c);
    Element* provideString(string& c);
    Element* provideString(wchar_t c);
    Element* provideString(u_uchar c);

Par exemple, ces lignes de code proviennent de l'interpréteur LispE lui-même.

En revanche, ce qui distingue ici defpat de l'exemple au-dessus, c'est la richesse et la complexité des motifs dont on peut se servir dynamiquement pour analyser une liste de mots et de catégories. Au lieu d'un appel statique compilé, on dispose ici d'une méthode très souple qui permet de se concentrer sur le code propre au motif détecté.

Cette façon de procéder permet en particulier le parcours d'arbre ou de graphe sans que jamais le programmeur ne se perde dans les entrelacs des cas particuliers. Si la liste des éléments évolue, il suffit souvent de rajouter une fonction supplémentaire pour prendre en compte ces nouveaux éléments sans bouleverser le reste du programme.