Votre chatbot peut-il passer pour un humain ?

Magazine
Marque
GNU/Linux Magazine
Numéro
219
|
Mois de parution
octobre 2018
|


Résumé

Pensez-vous que nous soyons capables de construire un chatbot, un agent conversationnel, qui soit capable d'interagir avec une personne au point de pouvoir passer...


Body

 

...  (au moins un tout petit peu) pour un humain ? C'est ce que nous allons essayer de réaliser dans cet article.

En 1950, Alan Turing a défini un test permettant d'évaluer la faculté d'un ordinateur à imiter un comportement humain lors d'une conversation. Ce test, baptisé par la suite Test de Turing, confronte un humain à un autre humain ou à une machine dans une discussion. Pour qu'une machine réussisse le test, il faut qu'elle parvienne à tromper un humain dans une conversation de 5 minutes. Un prix a d'ailleurs été créé, le prix Loebner, qui récompense chaque année le meilleur chatbot. Les règles de victoire varient d'une année à l'autre, mais il s'agit grossièrement d'un test de Turing.

Nous ne ferons pas passer un test de Turing à notre agent conversationnel, ce test n'étant pas exempt de faiblesses. Pour citer un exemple, les humains ont tendance à parler aux objets (vous ne vous êtes jamais adressé à votre ordinateur ?). Cet anthropomorphisme faussera nécessairement le jugement de l'humain placé face à la machine. Inversement, suivant le niveau de langage de l'humain se prêtant au test, il pourra considérer un autre humain lui faisant face comme étant une machine, car ayant un niveau de langage plutôt faible. À l'opposé, comment déterminer la nature d'une entité refusant de répondre à une question ? Humain ou machine ?

Ce test ne prouve donc rien, mais il était intéressant de le citer en introduction de cet article, et rien ne vous empêchera de le tenter lorsque votre programme sera fonctionnel… tout en sachant que vous ne pourrez en tirer aucune conclusion ! Notre chatbot sera codé en Python en utilisant un modèle Tensorflow[1][2] pour modéliser les conversations. Pour effectuer nos tests (les nôtres, pas ceux de Turing :-)), nous ferons en sorte que notre ordinateur soit capable de dialoguer (sommairement) avec nous. Ce sera notre HAL (voir « 2001 l'Odyssée de l'espace ») et les applications possibles par la suite seront plus importantes qu'en simulant un service client quelconque ou un vendeur de crêpe goût barbe-à-papa (à moins que vous ne soyez ce vendeur bien entendu).

1. Définir une conversation

Vous l'avez compris, comme nous allons utiliser Tensorflow nous allons avoir recours à l'apprentissage pour que notre programme puisse répondre à un panel important de phrases. Pour cela, nous allons employer une structure permettant de stocker les données d'apprentissage :

  • les règles (rules) :
    • un nom de règle (ruleName) ;
    • des motifs (patterns) ;
    • des réponses associées aux motifs (responses).
  • les phrases indiquant une incompréhension de la saisie utilisateur (unknown).

La structure qui paraît la plus simple à employer est ici le JSON :

{

    "rules": [

                 {

                     "ruleName":"...",

                     "patterns":[ ... ],

                     "responses":[ ... ],

                 }

             ],

    "unknown":[ ... ]

}

Pour un usage « intensif », il faudrait envisager un programme permettant de manipuler simplement cette structure en ajoutant/retirant des motifs et des réponses associés à une règle donnée. Comme ici nous ne recherchons qu'à tester un concept, je sauterai cette phase (en fait, j'ai surtout besoin de pages libres pour que mon article puisse tenir en entier et ce code n'a rien de compliqué à écrire…).

Voici le fichier rules.json que j'ai employé. Vous noterez un allègre mélange de langage familier et soutenu dans les règles. En toute rigueur il faudrait choisir un registre de langage particulier pour donner plus de consistance à notre agent (par exemple uniquement soutenu pour en faire une sorte de majordome) ou créer plusieurs règles permettant de s'adapter au registre de langage employé par l'interlocuteur (mais un humain se comporterait-il de la sorte ?). Quoi qu'il en soit, mon jeu de données est le suivant :

{

    "rules" : [

                 {

                     "ruleName" : "salutations",

                     "patterns" : ["Salut", "Bonjour", "Hello", "Coucou"],

                     "responses" : ["Salut à toi", "À vos ordres !", "Bien le bonjour"]

                 },

                 {

                     "ruleName" : "quitter",

                     "patterns" : ["À la prochaine", "Ciao", "Au revoir"],

                     "responses" : ["Au revoir", "Hasta la vista", "Je vous salue bien bas"]

                 },

                 {

                     "ruleName" : "heure",

                     "patterns" : ["Quelle heure est-il ?", "Peux-tu me donner l'heure ?", "Il est quelle heure ?", "Donne moi l'heure"],

                     "responses" : ["Il est {hour}h{mn}mn", "{hour}h{mn}mn d'après mon horloge", "{hour}h{mn}mn et {sec}s pour etre très précis"]

                 },

                 {

                     "ruleName" : "blague",

                     "patterns" : ["ahah", "hehe", "ohoh", "hihi"],

                     "responses" : ["ahahahahah !", "hehehehehehe !", "ohohohohohoh !"]

                 }

             ],

    "unknown" : ["Pardon ?", "Je ne comprends pas...", "Pourriez-vous reformuler votre phrase ?", "Plaît-il ?", "Quoi ???", "Hein ?"]

}

Dans l'élément rules, nous stockons les règles et dans unknown les réponses à apporter lorsque le chatbot ne reconnaîtra pas la phrase saisie par l'utilisateur.

Pour lire ces règles, nous allons créer un objet Rule pour stocker chaque règle et un objet RulesList pour lire le fichier JSON et renvoyer les règles. Commençons par Rule.py :

01: from dataclasses import dataclass, field
02: from typing import List
03: 
04: @dataclass
05: class Rule:
06:     ruleName : str
07:     patterns : List[str] = field(default_factory=list)
08:     responses : List[str] = field(default_factory=list)
09: 
10:     def getPatterns(self) -> list:
11:         return self.patterns
12: 
13:     def getResponses(self) -> list:
14:         return self.responses

Les dataclasses Python ont été présentées dans un précédent hors-série [3]. Il s'agit d'une nouveauté de Python 3.7 permettant de générer de manière automatique le constructeur. Ici les attributs sont définis dans les lignes 6 à 8 en indiquant leur type sous forme d'annotation (ce qui n'a aucune influence au niveau du code si vous utilisez d'autres types lors de la création d'une instance). Pour déclarer un type liste de chaînes de caractères, on doit utiliser une syntaxe particulière en important List depuis typing (ligne 2). La fonction field()[4] permet de définir une valeur par défaut (ici une liste vide) dans le cas de types « complexes ».

Les méthodes getPatterns() et getResponses() permettent simplement de renvoyer les valeurs des attributs patterns et responses. Notons là encore la présence d'annotations (lignes 10 et 11) permettant de faciliter la lecture du code (retour d'une liste), mais sans aucun impact fonctionnel.

Le fichier RulesList.py va permettre de définir la classe RulesList (Rules étant trop proche de Rule) pour lire l'ensemble des règles et y donner accès :

01: from dataclasses import dataclass, field
02: from typing import List
03: from Rule import Rule
04: import json
05: 
06: @dataclass
07: class RulesList:
08:     filename : str
09:     rules : List[Rule] = field(default_factory=list)
10: 
11:     def readRules(self) -> None:
12:         try:
13:             with open(self.filename) as data:
14:                 self.rules = json.load(data)
15:         except Exception as e:
16:             print(f'Error while reading {self.filename} : {e}')
17: 
18:     def getRule(self) -> Rule:
19:         for rule in self.rules['rules']:
20:             yield Rule(rule['ruleName'], rule['patterns'], rule['responses'])

21: 
22:     def getUnknown(self) -> list:
23:         return self.rules['unknown']

On retrouve la même structure que pour la classe Rule. La méthode readRules() va lire les données JSON dans le fichier dont le nom est contenu dans l'attribut filename et stocker le tout dans l'attribut rules. En cas d'erreur, le message est affiché à l'aide d'une f-string (ligne 16)... puisque les dataclasses nécessitent Python 3.7, autant en profiter complètement.

La méthode getRule() dans les lignes 18 à 20 est un générateur (emploi de yield) : à chaque appel, elle renvoie la règle suivante (donc nécessairement, une fois toutes les règles parcourues, elle retourne None, ce qui mettra fin à la boucle d'appel).

La méthode getUnknown() des lignes 22 et 23 renvoie la liste des phrases indiquant une incompréhension.

Enfin, voici le fichier main.py qui permet de tester nos classes et de charger les données du fichier rules.json :

01: from RulesList import RulesList
02: 
03: if __name__ == '__main__':
04:     rules = RulesList('rules.json')
05:     rules.readRules()
06:     for rule in rules.getRule():
07:         print(rule)

À l'exécution, nous obtenons :

$ python3.7 chatbot.py

Rule(ruleName='salutations', patterns='['Salut', 'Bonjour', 'Hello', 'Coucou']', responses='['Salut à toi', 'À vos ordres !', 'Bien le bonjour']')

Rule(ruleName='quitter', patterns='['À la prochaine', 'Ciao', 'Au revoir']', responses='['Au revoir', 'Hasta la vista', 'Je vous salue bien bas']')

Rule(ruleName='heure', patterns='['Quelle heure est-il ?', "Peux-tu me donner l'heure ?", 'Il est quelle heure ?', "Donne moi l'heure"]', responses='['Il est {hour}h{mn}mn', "{hour}h{mn}mn d'après mon horloge", '{hour}h{mn}mn et {sec}s pour etre très précis']')

Rule(ruleName='blague', patterns='['ahah', 'hehe', 'ohoh', 'hihi']', responses='['ahahahahah !', 'hehehehehehe !', 'ohohohohohoh !']')

2. De la dépendance aux modules

Tout se déroulait bien jusque-là et puis lors de l'installation de Tensorflow les problèmes ont commencé à apparaître... Au moment où ces lignes ont été écrites, Python 3.7 était disponible depuis deux mois… et Google n'a pas publié de version de Tensorflow pour cette version de Python ! Donc ce que nous avons vu précédemment, qui est particulièrement séduisant d'un point de vue syntaxique, ne peut pas être employé avec Tensorflow. J'ai décidé de laisser cette section telle qu'elle, car il ne vous sera guère compliqué de convertir le code en Python 3.6 (au pire, le code est présent sur le GitHub du magazine) et cela permet :

  • de manipuler enfin un peu de Python 3.7 ;
  • de mesurer combien il est important de se pencher sur les versions disponibles des différents modules utilisés dans un code (même si le module est maintenu par une grosse société...) ;
  • de s'apercevoir que la dépendance à des entités externes si vous souhaitez migrer un code existant peut être bloquante.

Nous allons donc installer notre environnement virtuel en 3.6.5 à l'aide de PyEnv [5] :

$ pyenv install 3.6.5

$ pyenv virtualenv 3.6.5 chatbot

$ mkdir chatbot

$ cd chatbot

$ pyenv local chatbot

$ pip install --upgrade pip

Voilà, nous sommes en 3.6.5, merci Google !

3. L'agent conversationnel

Nous allons maintenant classer nos données en phrases (les patterns), classes (les ruleName) et en racines de mots permettant de cibler plusieurs mots (les roots). Ces racines seront calculées grâce au module NLTK (Natural Language Toolkit) [6] après tokenisation (toujours grâce à NLTK). Nous emploierons également TFLearn qui fournit une API de haut niveau pour utiliser Tensorflow :

$ pip install nltk numpy scipy h5py tflearn tensorflow

Pour finaliser l'installation de NLTK, il va falloir passer par l'interpréteur interactif :

$ python

>>> import nltk

>>> nltk.download('punkt')

>>> quit()

Pour utiliser nos classes Rule et RulesList, nous allons maintenant créer une classe Chatbot. Cette classe étant assez imposante, je la commenterai progressivement.

001: import nltk
002: from nltk.stem.snowball import FrenchStemmer
003: from RulesList import RulesList
004: import random
005: import numpy
006: import tflearn
007: import tensorflow as tf
008: import os
009: import datetime

Les lignes 1 à 9 contiennent l'ensemble des modules dont nous aurons besoin et, pour certains d'entre eux, sur lesquels nous reviendrons dans la suite.

012: class Chatbot:
013:     ERROR_THRESHOLD = 0.25

Notre classe Chatbot va contenir un attribut de classe ERROR_THRESHOLD permettant de régler le seuil de tolérance des réponses. Nous acceptons toutes les probabilités supérieures à 0,25 (notre taux est donc réglé très bas et acceptera un peu n'importe quoi avant que nous ne l'affinions).

016:     def __init__(self, ignoreWords : list = ['?', '!'], verbose : bool = False, forceSave : bool = False):
017:         self.roots = []
018:         self.ruleList = []
019:         self.corpus = []
020:         self.ignoreWords = ignoreWords
021:         self.verbose = verbose
022:         self.forceSave = forceSave
023:         self.stemmer = FrenchStemmer()
024:         self.rules = None
025:         self.model = None

Le constructeur initialise l'ensemble des attributs. Des valeurs par défaut sont associées à ignoreWords (les mots qui seront ignorés lors de la tokenisation), verbose (affichage verbeux), et forceSave (réapprentissage forcé).

L'attribut stemmer contient une instance de FrenchStemmer qui sera utilisée pour obtenir la racine des mots.

Les attributs roots, ruleList et corpus contiendront respectivement les racines de mots, les noms de règles et l'ensemble des mots utilisés dans les phrases.

Les attributs rules (ensemble des règles) et model (modèle d'apprentissage) seront complétés par la suite et sont initialisés à None.

028:     def readRules(self, filename : str) -> None:
029:         self.rules = RulesList('rules.json')
030:         self.rules.readRules()

La méthode readRules() accepte en paramètre le nom d'un fichier qui sera lu grâce à l'objet RulesList créé précédemment.

3.1 Le pré-traitement

Pour manipuler les règles, il faut passer par une étape de pré-traitement :

033:     def preprocessing(self) -> None:
034:         for rule in self.rules.getRule():
035:             if self.verbose:
036:                 print('[preprocessing] Traitement de la règle', rule)
037:             ruleName = rule.getRuleName()
038:             for pattern in rule.getPatterns():
039:                 word = nltk.word_tokenize(pattern)
040:                 self.roots.extend(word)
041:                 self.corpus.append((word, ruleName))
042:             if ruleName not in self.ruleList:
043:                 self.ruleList.append(ruleName)
044:     
045:         self.ruleList = sorted(self.ruleList)
046:     
047:         self.roots = [self.stemmer.stem(w.lower()) for w in self.roots if w not in self.ignoreWords]
048:         self.roots = sorted(list(set(self.roots)))
049:     
050:         if self.verbose:
051:             print('Corpus de', len(self.corpus), 'phrases')
052:             print('Répartition en', len(self.ruleList), 'classes')
053:             print(len(self.roots), 'racines uniques :', self.roots)

La fonction preprocessing() va prendre en paramètre la liste des règles, parcourir ces règles (lignes 34 à 43) et pour chacune d'elle :

  • stocker son nom dans ruleList (lignes 42 et 43) qui, pour une meilleure lisibilité sera ensuite triée par ordre alphabétique (ligne 45) ;
  • isoler les mots de chaque pattern (ligne 39) et les ajouter dans la liste roots (ligne 40) et dans corpus sous forme de tuple contenant également le nom de la règle (ligne 41).

Les lignes 47 et 48 permettent d'obtenir les racines des mots (passés en minuscules pour ne pas avoir de problèmes de casse) grâce à la fonction stem() (une description de l'algorithme employé peut être trouvée en [7]).

Les lignes 50 à 53 permettent d'afficher des informations sur les opérations effectuées lorsque le paramètre verbose est passé à True.

À propos des listes : append() vs extend()

Pour ajouter des éléments dans une liste, on peut utiliser l'une des deux méthodes append() ou extend(). Ces deux méthodes ont la même finalité, mais sont légèrement différentes. En effet, considérons que nous disposions d'une liste l :

>>> l = [1, 2, 3, 4, 5]

>>> l

[1, 2, 3, 4, 5]

Nous souhaitons ajouter en fin de liste les entiers 67 et 8. Nous pourrions utiliser des append() successifs :

>>> l.append(6)

>>> l.append(7)

>>> l.append(8)

>>> l

[1, 2, 3, 4, 5, 6, 7, 8]

Mais que se passe-t-il si nous voulons ajouter en une fois ces trois nombres ?

>>> l.append([6, 7, 8])

>>> l

[1, 2, 3, 4, 5, [6, 7, 8]]

C'est là que la méthode extend() devient intéressante puisqu'elle va permettre de réaliser cette opération en « ouvrant » la liste qui lui est fournie en paramètre :

>>> l.extend([6, 7, 8])

>>> l

[1, 2, 3, 4, 5, 6, 7, 8]

Bien entendu si la liste passée en paramètre à extend() contient elle-même une liste, celle-ci sera conservée :

>>> l.extend([6, 7, 8, [9, 10]])

>>> l

[1, 2, 3, 4, 5, 6, 7, 8, [9, 10]]

3.2 L'apprentissage

Passons maintenant à l'apprentissage proprement dit :

056:     def trainData(self) -> None:
057:         training = []
058:         output = []
059:         outputEmpty = [0] * len(self.ruleList)
060:     
061:         for doc in self.corpus:
062:             group = []
063:             patterns = doc[0]
064:             patterns = [self.stemmer.stem(word.lower()) for word in patterns]
065:             for word in self.roots:
066:                 group.append(1) if word in patterns else group.append(0)
067:     
068:             outputRow = list(outputEmpty)
069:             outputRow[self.ruleList.index(doc[1])] = 1
070:     
071:             training.append([group, outputRow])
072:     
073:         random.shuffle(training)
074:         training = numpy.array(training)
075:     
076:         train_x = list(training[:,0])
077:         train_y = list(training[:,1])
078: 
079:         tf.reset_default_graph()
080:         nn = tflearn.input_data(shape=[None, len(train_x[0])])
081:         nn = tflearn.fully_connected(nn, 8)
082:         nn = tflearn.fully_connected(nn, 8)
083:         nn = tflearn.fully_connected(nn, len(train_y[0]), activation='softmax')
084:         nn = tflearn.regression(nn)
085:     
086:         self.model = tflearn.DNN(nn, tensorboard_dir='logs')
087: 
088:         if os.path.isfile('model.tflearn.index') and not self.forceSave:
089:             self.model.load('./model.tflearn')
090:             if self.verbose:
091:                 print('[trainData] Modèle chargé depuis ./model.tflearn')
092:             return None
093: 
094:         self.model.fit(train_x, train_y, n_epoch=1000, batch_size=8, show_metric=True)
095:     
096:         if not os.path.isfile('model.tflearn.index') or self.forceSave:
097:             self.model.save('./model.tflearn')
098:             if self.verbose:
099:                 print('[trainData] Modèle enregistré dans ./model.tflearn')

Les lignes 57 à 59 permettent la définition de nos variables de travail où outputEmpty est un vecteur dont la taille correspond au nombre de règles (groupes ou classes de règles).

Dans les lignes 61 à 71, nous associons à chaque élément du corpus un vecteur indiquant à quel groupe il appartient. Au final, training contiendra donc une liste de listes de vecteurs où le premier vecteur indiquera la position de la racine (par un 1) et le second vecteur indiquera la règle. Par exemple, avec les racines ['ahah', ..., 'salut', 'à'], 'salut' est en avant-dernière position et le vecteur correspondant est [0, ..., 1, 0]. Comme 'salut' appartient à la règle 'salutations', le second vecteur l'indiquera. Comme 'salutations' est la troisième règle sur quatre, nous aurons [0, 0, 1, 0]. Ces vecteurs sont séparés en train_x (racines ou premier vecteur de la liste) et train_y (règles ou second vecteur de la liste) dans les lignes 76 et 77.

Ces données sont ensuite employées pour créer un réseau de neurones (lignes 79 à 86). nn permet de préparer les données du réseau [8] et model définit le modèle et initialise le répertoire dans lequel seront stockées les données de log (tensorboard_dir) qui permettront de visualiser le réseau (voir encadré).

La ligne 94 lance l'entraînement sur le modèle.

Les lignes 88 à 92 et 96 à 99 permettent simplement de gérer le fait qu'il faille ou non lancer l'apprentissage en fonction de la présence ou de l'absence du fichier de sauvegarde ./model.tflearn.index et de la valeur de l'attribut forceSave.

Visualisation du réseau

L'outil Tensorboard vous permet de lancer un serveur contenant un panneau de contrôle (visible dans un navigateur sur http://localhost:6006). Comme nous avons placé nos fichiers de log dans le répertoire logs :

$ tensorboard --logdir logs

La figure 1 montre le réseau obtenu pour notre programme.

 

figure_01

 

Fig. 1 : Panneau de contrôle Tensorboard permettant de visualiser le réseau (affichage tronqué).

3.3 La recherche d'une réponse

Pour rechercher une réponse d'après notre modèle, plusieurs étapes vont être nécessaires, chacune isolée dans une méthode.

102:     def tokenize(self, sentence : str) -> list:
103:         words = nltk.word_tokenize(sentence)
104:         words = [self.stemmer.stem(word.lower()) for word in words]
105:         if self.verbose:
106:             print('[tokenize] Tokens :', words)
107:         return words

tokenize() permet d'obtenir les tokens d'une phrase (séparation de chaque mot dans la phrase). Pour bien voir ce que fait cette partie de code, le plus simple est de l'isoler dans un interpréteur Python :

$ python

>>> import nltk

>>> from nltk.stem.snowball import FrenchStemmer

>>> stemmer = FrenchStemmer()

>>> words = nltk.word_tokenize('Je suis en train de lire GNU/Linux Magazine !')

>>> print(words)

['Je', 'suis', 'en', 'train', 'de', 'lire', 'GNU/Linux', 'Magazine', '!']

>>> words = [stemmer.stem(word.lower()) for word in words]

>>> print(words)

['je', 'suis', 'en', 'train', 'de', 'lir', 'gnu/linux', 'magazin', '!']

Lors de la première étape, on obtient l'ensemble des mots de la phrase sous forme de liste, puis les racines en minuscule ('lir' pour la racine de 'lire', etc.).

110:     def searchGroup(self, sentence) -> numpy.array:
111:         tokens = self.tokenize(sentence)
112:         group = [0] * len(self.roots)  
113:         if self.verbose:
114:             print('[searchGroup] Racines :', self.roots)
115:         for tok in tokens:
116:             for i, word in enumerate(self.roots):
117:                 if word == tok:
118:                     group[i] = 1
119:                     if self.verbose:
120:                         print ('[searchGroup] Occurrence trouvée :', word)
121:     
122:         if self.verbose:
123:             print('[searchGroup] Groupe :', group)
124:         return(numpy.array(group))

La méthode searchGroup() va prendre une phrase en paramètre (sentence), la « tokeniser » (ligne 111) puis rechercher des occurrences de ses tokens dans les racines enregistrées. Le résultat sera un tableau (numpy.array) de la taille de la liste self.roots et contenant des   et des 1 : 1 pour une occurrence d'un token trouvé dans les racines et   sinon.

127:     def classification(self, sentence : str) -> list:
128:         groups = self.searchGroup(sentence)
129:         if not 1 in groups:
130:             return False
131: 
132:         results = self.model.predict([groups])[0]
133:         results = [[i, res] for i, res in enumerate(results) if res > Chatbot.ERROR_THRESHOLD]
134:         results.sort(key=lambda x : x[1], reverse=True)
135:         resultList = []
136:         for res in results:
137:             resultList.append((self.ruleList[res[0]], res[1]))
138:         return resultList

classification() va appeler searchGroup() sur une phrase passée en paramètre de manière à obtenir le tableau des occurrences. Si ce tableau n'est composé que de  , alors nous sommes incapables d'interpréter la phrase (lignes 129 et 130). Sinon, à l'aide du modèle calculé précédemment, nous allons déterminer les couples de résultats possibles en tenant compte de la tolérance fixée dans ERROR_THRESHOLD (ligne 133). Les résultats sont triés par ordre de probabilité descendante (ligne 134) et renvoyés sous forme de liste (lignes 135 à 138).

3.4 La composition d'une réponse

La création d'une réponse à une phrase saisie par l'utilisateur et maintenant simple, car il suffit d'appeler classification() :

141:     def response(self, sentence : str) -> str:
142:         results = self.classification(sentence)
143:         if self.verbose:
144:             print('[response]', results)
145:         if results:
146:             while results:
147:                 for rule in self.rules.getRule():
148:                     if rule.getRuleName() == results[0][0]:
149:                         return random.choice(rule.getResponses())
150: 
151:                 results.pop(0)
152: 
153:         return random.choice(self.rules.getUnknown())

Si un résultat a été trouvé (ligne 145), alors on parcourt l'ensemble des résultats possibles (ligne 146 et 151 pour changer de résultat), et on choisit aléatoirement une réponse parmi les réponses proposées dans les règles de même nom (lignes 147 à 149). Si l'on ne trouve pas de réponse (ou que results est à False ), alors on choisit aléatoirement une phrase indiquant l'incompréhension (ligne 153).

156:     def complete(self, sentence) -> str:
157:         date = datetime.datetime.now()
158:         sentence = sentence.replace('{hour}', str(date.hour))
159:         sentence = sentence.replace('{mn}', str(date.minute))
160:         sentence = sentence.replace('{sec}', str(date.second))
161:         return sentence

Dans notre fichier rules.json, certaines réponses étaient paramétrables (par exemple {hour} pour insérer l'heure courante). La solution n'est pas optimale, car il faudrait définir deux types de réponses (paramétrables ou non) et cibler plus précisément les remplacements (car ici ils sont tentés pour chaque réponse), mais la méthode complete() permet ici rapidement de compléter les phrases attendant des informations.

3.5 Interaction avec l'utilisateur

Pour interagir avec l'utilisateur, une simple boucle infinie sur un input() suffira :

164:     def interact(self) -> None:
165:         while True:
166:             sentence = input('> ')
167:             if sentence == '.':
168:                 break
169:             print(self.complete(self.response(sentence)))

Pour sortir de la boucle, il faudra taper uniquement un point en tant que phrase (lignes 167 et 168). La ligne 169 permet d'obtenir la réponse à la phrase sentence tout en la complétant avec complete().

3.6 Dialogue avec la machine

Le programme principal va consister à appeler les différentes fonctions précédentes : on crée une instance de Chatbot, on lit les règles du fichier rules.json, on lance le pré-traitement et l'apprentissage, puis on attend que l'utilisateur saisisse des phrases :

172: if __name__ == '__main__':
173:     chatbot = Chatbot(verbose=True)
174:     chatbot.readRules('rules.json')
175:     chatbot.preprocessing()
176:     chatbot.trainData()
177:     chatbot.interact()

Voici un exemple d'exécution avec tous les cas de figure que l'on peut rencontrer :

$ python Chatbot.py

[preprocessing] Traitement de la règle Rule(ruleName='salutations', patterns='['Salut', 'Bonjour', 'Hello', 'Coucou']', responses='['Salut à toi', 'À vos ordres !', 'Bien le bonjour']')

...

Corpus de 15 phrases

Répartition en 4 classes

24 racines uniques : ['ahah', 'au', 'bonjour', 'ciao', 'coucou', 'don', 'est', 'est-il', 'heh', 'hello', 'heur', 'hih', 'il', "l'heur", 'la', 'me', 'moi', 'ohoh', 'peux-tu', 'prochain', 'quel', 'revoir', 'salut', 'à']

...

[trainData] Modèle enregistré dans ./model.tflearn

> salut

[tokenize] Tokens : ['salut']

[searchGroup] Racines : ['ahah', 'au', 'bonjour', 'ciao', 'coucou', 'don', 'est', 'est-il', 'heh', 'hello', 'heur', 'hih', 'il', "l'heur", 'la', 'me', 'moi', 'ohoh', 'peux-tu', 'prochain', 'quel', 'revoir', 'salut', 'à']

[searchGroup] Occurrence trouvée : salut

[searchGroup] Groupe : [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0]

[response] [('salutations', 0.9369749)]

Bien le bonjour

« Salut » fait partie des règles, il est donc naturellement compris (taux de 0,93) et la machine répond correctement.

> Puis-je savoir qu'elle heure il est ?

[tokenize] Tokens : ['puis-j', 'savoir', "qu'el", 'heur', 'il', 'est', '?']

[searchGroup] Racines : ['ahah', 'au', 'bonjour', 'ciao', 'coucou', 'don', 'est', 'est-il', 'heh', 'hello', 'heur', 'hih', 'il', "l'heur", 'la', 'me', 'moi', 'ohoh', 'peux-tu', 'prochain', 'quel', 'revoir', 'salut', 'à']

[searchGroup] Occurrence trouvée : heur

[searchGroup] Occurrence trouvée : il

[searchGroup] Occurrence trouvée : est

[searchGroup] Groupe : [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

[response] [('heure', 0.81043696)]

18h8mn d'après mon horloge

La phrase complète proposée pour obtenir l'heure n'est pas exactement une phrase insérée dans la liste des règles, mais là encore la machine parvient à comprendre la demande (avec un taux un peu plus faible de 0,81).

> Tu connais Linux Mag ?

[tokenize] Tokens : ['tu', 'con', 'linux', 'mag', '?']

[searchGroup] Racines : ['ahah', 'au', 'bonjour', 'ciao', 'coucou', 'don', 'est', 'est-il', 'heh', 'hello', 'heur', 'hih', 'il', "l'heur", 'la', 'me', 'moi', 'ohoh', 'peux-tu', 'prochain', 'quel', 'revoir', 'salut', 'à']

[searchGroup] Groupe : [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

[response] False

Je ne comprends pas...

Ici, aucun des termes n'est connu et donc le programme nous signale son incompréhension.

> Je vais au tennis

[tokenize] Tokens : ['je', 'vais', 'au', 'ten']

[searchGroup] Racines : ['ahah', 'au', 'bonjour', 'ciao', 'coucou', 'don', 'est', 'est-il', 'heh', 'hello', 'heur', 'hih', 'il', "l'heur", 'la', 'me', 'moi', 'ohoh', 'peux-tu', 'prochain', 'quel', 'revoir', 'salut', 'à']

[searchGroup] Occurrence trouvée : au

[searchGroup] Groupe : [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

[response] [('blague', 0.57257885), ('quitter', 0.42699775)]

ahahahahah !

Nous finissons par une erreur de compréhension due à notre tolérance trop grande (0,25) avec un ERROR_THRESHOLD de 0,6 le programme n'aurait pas compris la demande puisque les résultats ont une confiance seulement de 0,57 et 0,42.

Conclusion

Avec un ensemble de règles suffisamment conséquent, un jeu de réponses important permettant de varier les réponses à une même question, et un niveau de langage homogène (voire une personnalité se dégageant de par le vocabulaire et les tournures de phrases employés), notre chatbot peut éventuellement donner le change quelques instants à condition de régler la tolérance de manière un peu plus stricte.

En plus de cette rigueur dans la création des règles, on peut imaginer encore d'autres améliorations :

  • Plutôt que de répondre qu'une phrase n'a pas été comprise, on pourrait questionner l'utilisateur sur la réponse attendue et enrichir le fichier rules.json. Le chatbot apprendrait ainsi au fur et à mesure de son contact avec les utilisateurs… mais serait vite démasqué puisque demandant en quelque sorte qu'on l'aide à se programmer. Dans ce cas de figure, il faudrait penser à relancer l'apprentissage après chaque modification du fichier de règles ;
  • On pourrait choisir de dialoguer oralement avec la machine en utilisant des techniques de STT (Speech To Text) et TTS (Text To Speech) [9].

La réponse à la question posée en titre de cet article est donc « non », pour peu que la personne s'adressant à la machine dispose d'un minimum d'intelligence. Un chatbot dédié à une action spécifique (assistance clientèle d'un opérateur de téléphone ou autre) ne sera confronté qu'à un vocabulaire restreint. Ce sont toujours les mêmes questions qui reviennent et c'est pour cela qu'à l'époque où les gens savaient rechercher une information il y avait les FAQ (Frequently Asked Questions) : 99% des questions avaient déjà été posées et il suffisait alors de chercher la réponse. De nos jours, bon nombre de personnes ne prennent pas cette peine. Rendez-vous sur un forum quelconque d'assistance et vous constaterez que ce sont toujours les mêmes questions qui reviennent ! Dans ces conditions, un chatbot n'aura aucun mal à passer pour un humain.

À vous maintenant de faire vos tests et de tenter éventuellement (par pur jeu) un test de Turing...

Références

[1] Site officiel de Tensorflow : https://www.tensorflow.org/

[2] COLOMBO T., « Apprentissage supervisé à l'aide de réseaux de neurones », GNU/Linux Magazine n°198, novembre 2016 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-198/Apprentissage-supervise-a-l-aide-de-reseaux-de-neurones

[3] COLOMBO T., « Un aperçu des nouveautés de Python 3.7 : le décorateur @dataclass », GNU/Linux Magazine HS n°98, septembre/octobre 2018.

[4] Documentation de @dataclass : https://docs.python.org/3/library/dataclasses.html

[5] COLOMBO T., « Installez simplement la dernière version de Python avec PyEnv », GNU/Linux Magazine n° 216, juin 2018 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-216/Installez-simplement-la-derniere-version-de-Python-avec-Pyenv

[6] Site officiel de NLTK : https://www.nltk.org/

[7] French stemming algorithm : http://snowball.tartarus.org/algorithms/french/stemmer.html

[8] Documentation de TFLearn : http://tflearn.org/getting_started/, http://tflearn.org/layers/core/, http://tflearn.org/layers/estimator/#regression et http://tflearn.org/models/dnn/

[9] COLOMBO T., « Parlez à votre ordinateur et faites-vous comprendre ! », GNU/Linux Magazine n° 188, décembre 2015 : https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMF-188/Parlez-a-votre-ordinateur-et-faites-vous-comprendre

 

Par le même auteur

Édito

Magazine
Marque
GNU/Linux Magazine
Numéro
231
|
Mois de parution
novembre 2019
|
Résumé

« [...] dans le passé, aucun gouvernement n'avait le pouvoir de maintenir ses citoyens sous une surveillance constante. L'invention de l’imprimerie cependant, permit de diriger plus facilement l'opinion publique. Le film et la radio y aidèrent encore plus. Avec le développement de la télévision et le perfectionnement technique qui rendit possibles, sur le même instrument, la réception et la transmission simultanées, ce fut la fin de la vie privée. »

George Orwell, 1984

Édito

Magazine
Marque
GNU/Linux Magazine
HS n°
Numéro
105
|
Mois de parution
novembre 2019
|
Résumé

Tout est toujours une histoire de compromis en informatique :

- Vais-je privilégier la vitesse d’exécution ou minimiser l’utilisation de la RAM ?

- Utiliser une architecture complexe et fortement maintenable ou développer rapidement un petit script « jetable » ?

- Etc.

Enfouissement de données dans un document LibreOffice

Magazine
Marque
GNU/Linux Magazine
HS n°
Numéro
105
|
Mois de parution
novembre 2019
|
Domaines
Résumé

Cacher des données dans un document, c'est de la stéganographie [1]. Ici ce qui va nous intéresser, ce n'est pas vraiment de dissimuler les données, mais de les lier à un document LibreOffice pour ajouter des métadonnées. Ces données n'étant utiles qu'à notre usage personnel, nous allons les enfouir pour qu'elles ne perturbent pas nos collaborateurs.

Créez des templates pour vos projets Python avec Cookiecutter

Magazine
Marque
GNU/Linux Magazine
Numéro
231
|
Mois de parution
novembre 2019
|
Domaines
Résumé

Il peut être rébarbatif de taper toujours les mêmes lignes, lorsque l'on démarre des projets dans un même domaine. Les risques d'erreurs sont grands et le copier-coller n'est pas non plus exempt de risques d'erreurs, sans parler de l'impossibilité de personnaliser le contenu. Les modèles - ou templates - constituent La solution...