QuBot: 3. Естественный язык


Введение

В QuBot логика переходов между состояниями может определяется не только кнопками, но и текстом из поля ввода. Из этого текста также могут извлекаться различные данные (например электронный адрес клиента или названия товаров). Этими задачами занимается NLU-модуль движка QuBot.


Извлечение данных

При объявлении слота (в разделе slots) указывается его тип type. Кроме базовых типов, существуют типы для данных специального вида: http (веб-адрес), email (адрес электронной почты), phone (телефон) и т.д. Объявим, например, слот типа email:


slots:
    EMAIL:                                       # почта клиента
        type: email                              # слот типа email
Теперь его можно извлечь из строки ввода:

#states:
    GET_EMAIL:
      - text: "Введите пожалуйста свой email."
      
      - input:                                   # свободный ввод
          - extract: [ EMAIL ]                   # извлекаем из строки слот EMAIL
          - if: $EMAIL != None                   # проверяем извлёкся ли он
            then:
              - state: THANK_YOU                 # идём дальше, иначе повторим запрос

В поле extract раздела input передаётся список (квадратные скобки) имён слотов которые необходимо извлечь из текста. В примере это произойдёт со слотом EMAIL. Если адреса почты в тексте нет, то значением слота будет зарезервированное слово None. Поэтому в условии if проверяется извлёкся email или нет. Если слот (значение начинается с $) не равно None, то устанавливается состояние перехода THANK_YOU. Если же условие не выполнится, то, так как других state в состоянии нет, снова будет показан текст с просьбой ввести email (после ввода текста произойдёт повтор состояния).

Аналогично извлекаются слоты других типов (int, float, http, phone, date, time). Извлекаются данныу в том порядке, в котором перечислены в списке. В разделе input могут быть несколько объектов extract.

При извлечении чисел, надо иметь ввиду, что int не допускает наличие точки. Пусть есть слот типа int с именем INT, и слот FLOAT типа float. Для extract: [INT, FLOAT] получатся следующие результаты:


"1   3.14"   ->    INT: 1,      FLOAT: 3.14 
"1,3.14"     ->    INT: 1,      FLOAT: 3.14     # "русские" запятые в числах не работают
"1.0 3.14"   ->    INT: None,   FLOAT: 1.0      # INT не сработал, первый FLOAT 1.0
"a1  3.14"   ->    INT: None,   FLOAT: 3.14     # INT не сработал - число прижато к слову
Для типа float наличие точки не обязательно. Изменив порядок извлечения слотов extract: [FLOAT, INT], получим:

"1   2.0"   ->     FLOAT: 1.0,  INT: None      


Намерения пользователя

NLU (natural-language understanding) устроен следующим образом. Сначала необходимо перечислить множество классов, к одному из которых будет отнесен введенный текст. Эти классы называются intents (намерения). Примерами намерений могут быть: приветствие, согласие, отказ, вопрос об имени и т.п. Для каждого намерения перечисляются его примеры, чтобы система машинного обучения поняла, что от неё требуется. Обычно примеров должно быть не менее 10 для каждого намерения.

Каждый пример превращается в вектор признаков, который используется при обучении. Признаками является: наличие слов, пар слов (биграмм слов), букв, пар букв (биграмм букв) и т.д. Например, в тексте "да, котик", при наличии биграмм слов и букв значимыми признаками будут:


 "да", "котик", "да котик", 
 "a", "д", "и" "к", "о", "т", 
 " д", "a ", " к" "ко", "от", "ти", "ик", "к "
Благодаря n-граммам система классификации может "простить" опечатки и отнести "котек" к этому же намерению. Хотя, конечно, если в тексте не встретится ни одного слова из примеров, намерение вряд ли будет распознано. Впрочем, машинное обучение - это волшебный чёрный ящик и любые чудеса возможны.

Знаки препинания игнорируются, кроме знака вопроса, который является значимым признаком. По умолчанию текст приводится к нижнему регистру, т.к. в чатах люди обычно ленятся соблюдать правила правописания.

По-возможности намерения должны быть хорошо различимы. Например, система будет путаться если в одном намерении YES будут примеры "да", "конечно", а во втором намерении AGREE - примеры "да, согласен", "кончено, буду". В этом случае лучше объединить эти намерения в одно или быть готовым, к их совместной обработке.


Объявление намерений

Для описания намерений, в корне проекта необходимо объявить раздел nlu с подразделом intents (намерения). Каждое намерение имеет уникальное имя (ниже YES, NO, NEED_HELP):


nlu:
    intents:
        YES: ["да", "конечно", "несомненно", "пожалуй", "охотно", "давайте"]
        NO:  ["нет", "пока нет", "никогда", "ни за что", "не хочу", "не сегодня"]
    
        NEED_HELP:        
            - "ты меня не понимаешь"
            - "соедини меня с человеком"
            - "что за бестолковый бот"
            - "давай оператора"

В каждом намерении перечисляется список обучающих примеров. При этом не предполагается, что фразы должны быть в точности такими, но чем больше примеров, тем лучше система учится распознавать намерение.

Если интерфейс бота мультиязычный, каждое намерение может быть объектом, ключи которого являются языками:


nlu:
    intents:
        LIKE:
            ru: ["мне нравится", "очень не плохо", "замечательно", "мне подходит"]
            en: ["I'd like it", "I like it", "not bad at all", "great", "suits me"]
Если языки не перечислены, считается, что примеры принадлежат языку по умолчанию (английскому en). В принципе, намерения разных языков можно разносить по различным файлам, подключая их, как обычно, при помощи includes.


Намерения в input

После определения намерений, их можно использовать в разделе input данного состояния:


states:
    SCR_ARE_YOU_AGREE:
    
      - text: "Вы согласны со мной?"
      
      - input:                                   # свободный ввод
          - intents:                             # запускаем классификатор
                YES:
                  - state:  SCR_AGREE
                NO:
                  - state:  SCR_DISAGREE
                  
                NLU_UNKNOWN:                     # низкая уверенность
                  - state:  SCR_DONT_UNDERTAND
                  
                NLU_DEFAULT:                     # ни одно из выше перечисленных
                  - state:  SCR_OTHER_TOPIC
                

Кроме объявленных в nlu намерений, есть ещё два. Намерение NLU_UNKNOWN сработает, если NLU-модуль не может с уверенностью отнести текст ни к одному из известных ему намерений (степеню уверенности можно управлять). Блок "намерения" NLU_DEFAULT сработает, если распознанного намерения (которым может быть и NLU_UNKNOWN) нет в списке input.intents.


Реакция на болтовню

Различные фразы (намерения) человека, отклоняющиеся от основной цели бота, называются болтовней (chitchat). Удобно собирать их в одном месте. Пусть, например, есть некоторое состояние, в котором обрабатывается текстовый ввод:


#states:
    SCR_INTRO:                                   # экран приветствия
    
      - text:  "Привет"      
      
      - input:
          - intents:
                GREET: 
                  - state: SCR_GREET             # продолжим беседу
                  
                NLU_DEFAULT:            
                  - run: RUN_CHITCHAT            # откликнемся на вопросы не в тему   
Если на приветствие человек не введёт чего-то, что распознается как ответное приветствие (намерение GREET), то сработает намерение по умолчанию (NLU_DEFAULT), в котором вызовется состояние RUN_CHITCHAT. В этом состоянии, в объекте intents можно прописать ответы на текст человека:

#states:
    RUN_CHITCHAT:                                # ответы на болтовню  
    
      - intents:
            WHAT_IS_YOUR_NAME:
              - text: "С утра меня звали Анна. Но для Вас - просто Нюшенька."
              
            YOU_ARE_FOOL:
              - text: "Маленькую каждый может обидеть. Сам такой."
                                  
            NLU_UNKNOWN: 
              - random:
                  - text: "Не поняла. Напишите другими словами"
                  - text: "Простите, не совсем поняла. Что Вы имели ввиду?"     

Объект random в последнем намерении (текст не распознан) выбирает случайным образом один объект из списка. Выше это будет один из текстов, что повышает разнообразие ответов бота.


Разнообразие ответов

Ещё один способ повышения разнообразия ответов, это использования объекта again:


      - intents:
            ARE_YOU_BOT:
              - again: WAS_ARE_YOU_BOT
                cases:
                    1:  
                      - text: "Да, я искусственный разум, созданный компанией QuData."
                    more:  
                      - text: "Да, кожаный мешок, набитый костями. Я не человек."

Этому объекту передаётся имя слота WAS_ARE_YOU_BOT типа int, который необходимо определить в разделе slots. При каждом попадании в again этот слот увеличивается на единицу. При первом срабатывании намерения ARE_YOU_BOT, выполнится список действий, идущих после ключа 1. Затем могут идти любые целочисленные ключи. Список под ключом more выполнится, если значение WAS_ARE_YOU_BOT превысит все целочисленные ключи из again.

Естественно, again можно использовать и вне intents. Например, при отсутствии кнопок, диалог с человеком необходимо как-то направлять. Хорошим выходом является стратегия вопросов к человеку. Но это не всегда уместно. Поэтому в состоянии можно поставить again в котором с некоторым шагом давать наводящие замечания, направляющие беседу:


   SCR_FREE_TALK:

      - again: WAS_TALK
        cases:
            3:
              - text: "Может хочешь выпить? У нас есть сок, кола, чай, кофе."
            5:
              - text: "Можно и перекусить. Хлеб, масло, икра, лимон?"
            7:
              - text: "Если понадобится узнать время - спрашивай. Я всё знаю"          

      - input:
          - intents:                       
          - run: RUN_CHITCHAT                   # откликнемся на вопросы не в тему         
В этом состоянии происходит свободная беседа, при которой в RUN_CHITCHAT анализируются намерения, выводится соответствующий им текст и снова по goto происходит переход в SCR_FREE_TALK, где с шагом в 2 захода последовательно появляются фразы подсказывающие, что у бота ещё можно спросить или попросить.

Ключ intents: без значения (ключей) означает просто запуск расспознования намерения в строке ввода, которая уже анализировалась в RUN_CHITCHAT.


Раздел default

В корне проекта (там где states и slots) может находится раздел default. Ключами этого раздела выступают input и message (см. следующий документ).

Если произошёл ввод текста и в текущем состоянии есть раздел input, то всегда выполняется он. Есл же его нет, но он есть в default, то выполнится input из этого раздела (иначе повторно будет запущено текущее состояние).

Таким образом, в разделе default можно собирать универсальные реакции на ввод пользователя, чтобы не повторять их в каждом состоянии.


Извлечение сущностей

Иногда из текста нужно извлекать некоторые слова. Это делается либо при срабатывании конкретного намерения, либо независимо от намерений. Например:


💻: "У нас есть сок, кола и джин. Что Вы хотите?"
🙎: "Хочу джин"                                   ->  WANT_STR: джин
🙎: "Хочу колу и сок"                             ->  WANT_LST: [кола, сок]

Одно слово можно извлекать в текстовый слот, а одновременно несколько слов в слот типа список. Объявим, например, два слота:


slots:
    WANT_STR:
        type: str        # тип строка
    WANT_LST:
        type: list       # тип список
и сделаем следующее состояние:

states:
    SCR_EXTRACT_LIST:
          
      - input:
            extract:
              - { WANT_STR:  [сок, [кола, колу], джин ]  }
              - { WANT_LST:  [сок, [кола, колу], джин ]  }
Свойство extract по-прежнему является списком. Но это список не слотов (которые тоже там могут быть), а объектов. Ключи объектов - это имена слотов, а значения - списки строк или списков строк. В этом примере, мы хотим, чтобы в строковый слот WANT_STR было помещено первое слово из встреченных в списке (сок, кола, колу, джин), а в слот WANT_LST типа list - все слова из списка, которые были найдены в строке.

Если элементом списка является не строка, а список, также ищутся все слова из этого списка. Не зависимо от того, какое из них было найдено, вернётся первое слово списка. Таким образом можно перечислять морфологические варианты слов, типичные опечатки и т.п.


"хочу колу и сок"    ->     WANT_STR: сок;    WANT_LST: сок, кола

Так как "сок" в списке стоял первый, сначала в тексте ищется он. В строковый слот WANT_STR помещается только одно значение, поэтому в нём оказывается "сок". В слот-список WANT_LST помещаются все найденные сущности, причём для "колу" возвращается базовая форма "кола", идущая первой в подсписке [кола, колу].


Сущности в разделе nlu

Хотя примеры сущностей можно перечислять непосредственно в extract, лучше их выносить в раздел nlu, в подраздел entities:


nlu:
    entities:       # нет сока, дам сок, хочу сока, доволен соком, думаю о соке
                    
        DRINKS:     # возможные значения сущности "Напитки"
          - кофе
          - [чай, чая, чайка, чайку]
Затем в extract сущность DRINK можно использовать следующим образом:

              - extract:                         
                  - { WANT_LST:  $DRINKS }                              

Анализ намерений

Подбор примеров для намерений NLU требует особой внимательности. Если в разных намерениях будут схожие примеры, то распознание верного намерения будет затруднено. Поэтому следует анализировать качество сформированных намерений. Для этого в локальной версии, в секции nlu можно добавить объект report c именем html-файла, в который будет сохраняться отчёт об обучении намерений:


nlu:
    report:  nlu.html
    intents:
        ...
После запуска бота (достаточно увидеть первый экран), можно открыть в браузере файл nlu.html (он будет в корне). Там находится информация следующего вида:

Параметр Features означает количество признаков, которые используются при классификации текста. Он не должен быть очень большим (10000 и более), иначе работа бота может замедлиться. Как этим управлять будет объяснено ниже.

Параметр Score означает качество классификации приведенных в разделе intents примеров. Если он равен 1 - все примеры отнесены к своим классам (обучающие примеры непротиворечивы). Если значение Score существенно меньше единицы, то в примерах существуют проблемы.

Более детальную информацию о качестве классификации даёт матрица расхождений (Confusion matrix). Она показывает, сколько примеров данного намерения (строчки) отнесено к тому или иному намерению (по столбикам идут намерения в том же порядке). Если в таблице все числа стоят на диагонали, это означает хорошее качество классификации намерений. Если для некоторых намерений это не так, стоит проанализировать их примеры.


Тестирование намерений

Тестирование NLU движка можно проводить вне бота. Для этого в разделе nlu необходимо добавить раздел tests, где перечисляются тестируемые намерения и примеры тестов для них. Они могут отличаться от примеров intents, содержать опечатки и тому подобные отклонения от обучающих данных. Рассмотрим пример:


nlu:
    report:  nlu.html        # отчёт об обучении генерится при запуске
    logger:  nlu_log.txt     # log-файл с намерениями в процессе работы
    
    intents:                 # намерения проекта
        YES:         ["да", "конечно",  "пожалуй", "охотно", "давайте"]   
        ARE_YOU_BOT: ["ты бот?","ты не человек?","ты живой?","я разговариваю с человеком?"]
        
    tests:                   # тесты запускаются при генерации nlu.html 
        en:                  # язык по умолчанию (если несколько, делаем en: ru: ...)
        
            intents:         # тесты с указанием намерения:
                YES:         ["пажалуй",  "пажалуй, да","конешно","а давайте"]
                ARE_YOU_BOT: ["ты новерное бот?", "ты точно не человечек!"]
                            
            inputs:          # тесты без указания намерения:
                - Как тебя зовут?
                - Сколько тебе лет?        
        

После запуска бота, в файле nlu.html появится следующая информация:

Тестовые примеры из каждого намерения повторяются. Затем идёт намерение к которому его отнёс NLU-движок. Если это сделано верно, это намерение будет зелёного цвета, иначе - красного. Перед именем намерения в квадратных скобках стоит вероятность (степень уверенности) отнесения примера к намерению. Затем идёт список намерений, отсортированных в порядке убывания уверенности.

Обратим внимание, что намерение YES превраатилось в True. Связано это с тем, что в yaml-синтаксисие слова yes, YES, true превращаются в True. Поэтому, лучше подобное намерение называть, например I_YES.


Настройка NLU

В сложных случаях можно управлять процессом формирования признаков, использующихся при классификации. Для этого в раздел nlu добавляется следующий блок:


nlu:
    pipeline:  
        lower:        true    # приводит к нижнему регистру
        ngram_words:  2       # n-граммы слов
        ngram_chars:  3       # n-граммы букв
        char_wb:      true    # к словам добавляются ограничивающие пробелы        
        
        threshold:    0.5     # порог для вероятности срабатывания классификатора

Все эти параметры заданы по умолчанию, поэтому специально этот раздел повторять не нужно.

Чем больший словарь используется в примерах намерений, тем больше признаков возникнет при машинном обучении. Если положить ngram_words: 1, будет учитываться только факт наличия слова в тексте. При ngram_words: 2, по-мимо одиночных слов, используются упорядоченные пары слов в последовательности. Аналогично, при ngram_chars: 3 к признакам добавляются одиночные буквы, пары и тройки букв. Это позволяет достаточно эффективно бороться с опечатками в словах. Тем не менее, чтобы снизить размерность (Features), можно первым делом, уменьшить число n-грамм букв (ngram_chars).

Важным параметром с которым стоит экспериментировать - это порог срабатывания threshold. Если вероятность наиболее вероятного намерения выше этого порога, то текст относится к этому намерению. Иначе генерится намерение NLU_UNKNOWN. Если параметр threshold сделать малым, возникнут ложные срабатывания (введена фигня, которая классифицируется как одно из намерений). Если же его сделать слишком большим, то верно классифицированное намерение возвращаться не будет. Чтобы правильно подобрать этот параметр, необходимо смотреть на типичные вероятности для примеров из раздела tests.