ML: Чатбот RASA: Введение


Введение

Движок диалоговых систем RASA, при помощи современных методов глубокого обучения, позволяет создавать чат-боты для различных предметных задач. RASA состоит из двух частей: NLU (обработка естественного языка) и Core (логика диалогов).

В NLU входной текст от пользователя относится к одному из заранее предопределённых классов, называемых намерениями (intent). Например, фразы "здравствуйте" или "добрый день" могут принадлежать одному намерению с названием greet (приветствие). Независимо от того, в какой форме человек поздоровался, ядро RASA далее оперирует только именем намерения greet. Кроме классификации намерений, NLU извлекает из текста предопределённые сущности (entity), такие как имена, числа и т.п., которые можно использовать в диалоге.

Возможные ответы бота (responses) также имеют формальные имена (начинающиеся с префикса utter_).
Их называют действиями (action). С каждым из этих откликов связывается текст бота на естественном языке. Примеры диалогов (stories), на которых происходит обучение, состоят из последовательностей намерений пользователя и действий бота. RASA должна "запомнить" эти последовательности и научиться адекватно предсказывать отклик в незнакомых ситуациях. В качестве откликов можно использовать скрипты, написанные на Python. В них реализуется сложное поведение или извлекается информация из базы данных.


Простой пример

| config.yml
| domain.yml
+-data
|     nlu.yml
|     rules.yml
|     stories.yml
После установки RASA, в консоле следует запустить команду "rasa init", которая предложит ввести имя директории и после ответа "Y" создаст новый проект.
На вопрос: "Do you want to train an initial model?" отвечаем - нет ("N"). Отредактируем в этом проекте файл data/nlu.yml со списком намерений
(далее везде опускается необязательная первая строка version: "2.0"):
nlu:                                        # data/nlu.yml
- intent: greet                             # намерение называется greet
  examples: |                               # человек 🙎 здоровается с ботом
    - привет                                # это примеры приветствия
    - здравствуй
    - добрый день

- intent: goodbye                           # намерение называется goodbye
  examples: |                               # человек 🙎 прощается с ботом
    - пока                                  # это примеры прощания
    - до завтра
    - до свидания
Этот бот будет понимать два намерения человека (приветствие и прощание). На практике, чем больше приведено примеров для каждого намерения, тем лучше RASA учится их классифицировать.

В корневом файле domain.yml, в разделе responses перечислим текстовые отклики (ответы) бота:
responses:                                  # domain.yml
  utter_greet:                              # название ответа
  - text: Привет, рад встречи.              # случайно, одна из этих фраз
  - text: Как я рад тебя встретить!

  utter_goodbye:
  - text: До скорой встречи!
    image: "https://i.imgur.com/nGF1K8f.jpg"# послать вместе с текстом картинку 

Все файлы RASA версии 2.0 используют yaml-синтаксис. В нём важны отступы и выравнивание блоков по горизонтали. Поэтому не стоит пользоваться табуляцией (её следует заменять на пробелы). После символа '#' идёт строчный комментарий, который RASA игнорирует. В списках, после тире "-" должен быть пробел, а, если текст содержит двоеточие или тире (элементы разметки yaml-файла), то его необходимо окружить двойными кавычками "...". Если что-то идёт не так, можно проверить файл валидатором yamlchecker.com.

Истории (stories) - это примеры диалогов на которых бот учится правильно реагировать в зависимости от предыстории разговора. В файле data/stories.yml создадим пока единственную историю (в реальности описываются примеры множества историй):

stories:                                    # data/stories.yml
- story: привет и пока                      # произвольное описание содержания истории
  steps:
  - intent: greet                           # 🙎 привет
  - action: utter_greet                     # 💻 Привет, рад встречи  
  - intent: goodbye                         # 🙎 до завтра
  - action: utter_goodbye                   # 💻 До скорой встречи!

Наконец, в файле data/rules.yml пока убьём все строки в разделе "rules:", а в файле настроек config.yml поменяем язык на русский:

language: ru

После тренировки RASA (при помощи запуска в консоли команды "rasa train"), можно пообщаться с полученным ботом (команда "rasa shell"):

Your input ->  превет
Привет, рад встречи
Your input ->  покеда
До скорой встречи!
Обратим внимание на опечатку в слове "привет" и незнакомое системе слово "покеда", с которыми вполне справился NLU-движoк.

Для общения с ботом, вместо "rasa shell", можно также использовать скрипт !agent.py. Он позволяет отслеживать, чуть больше информации, чем просто диалог с ботом и сохраняет логи общения в файле !agent.log. Этот скрипт, вместе с примерами данного раздела, находится в корне проекта Bot01_Simple.zip. Если выдаваемой скриптом !agent.py информации недостаточно, стоит попробовать стандартный отладочный режим "rasa shell --debug" :).


Обучение намерениям

Для отнесения фразы пользователя к одному из намерений, в NLU-модуле обучается сложная нейронная сеть.
Для её работы, входные тексты превращаются в векторы (массивы) вещественных чисел, называемых признаками (futures). При построении вектора признаков используются технологии мешка слов (bag of words), векторизации слов embedding (word2vec) и N-грамм букв (отдельные буквы N=1, пары последовательных букв N=2 и т.д.). Соответствующие настройки в файле config.yml, позволяют использовь предобученные для данного языка векторы слов, что позволяет NLU понимать синонимы и близкие по смыслу слова, не описанные в примерах intent. Благодаря N-граммам, RASA достаточно эффективно борется с опечатками в словах.

Чем больше приведено примеров для данного намерения, тем лучше (обычно) обучается система. Однако, при этом существует опасность пересечения похожих фраз из различных намерений, что может снизить качество классификации. Поэтому файл data/nlu.yml с намерениями (как и истории) необходимо время от времени тестировать. Подробнее об этом будет рассказано в конце этого документа.

При обучении (бегущий прогресс на экране после запуска "rasa train") стоит следить за ошибками обучения (t_loss, i_acc). Если t_loss (общая ошибка) существенно больше 1.5, и/или i_acc (точность определения намерения) - существенно меньше единицы, возможно, стоит увеличить число эпох обучения в конвейере (например, удвоить их):

pipeline:                                   # config.yml
   # ...
   - name: DIETClassifier                   # классификатор намерений и сущностей
     epochs: 200                            # число эпох обучения
Если это не помогает, необходимо проанализировать примеры в намерениях (intens), на предмет их излишней схожести и провести анализ результатов тестирования проекта.


Сущности

Намерения (intent) могут содержать сущности (entity), которые NLU-движок способен извлекать из текста. Например, добавим в файл data/nlu.yml третье намерение my_name:

- intent: my_name                           # data/nlu.yml
  examples: |
    - меня зовут [Настя](PERSON)
    - меня зовут [Анастасия](PERSON)
    - зовите меня [Бонд](PERSON)
    - зовите меня [Изя](PERSON)
    - друзья зовут меня [Нюша](PERSON)
    - друзья зовут меня [Пандочка](PERSON)
    - [Маша](PERSON)
    - [Саша](PERSON)    

В квадратных скобках стоит часть текста (в примере - конкретное имя), а в круглых скобках, сразу после квадратных скобок без пробела(!!!) идёт имя сущности. Пробелов внутри круглых скобок быть не должно!
В файле domain.yml необходимо перечислить все намерения и используемые в них сущности:

intents:                                    # domain.yml
  - greet                                   # используемые намерения
  - goodbye
  - my_name

entities:
  - PERSON                                  # извлекаемые из намерений сущности
После обучения (rasa train), можно протестировать извлечение сущностей в недиалоговом режиме (просто вводим фразы) при помощи скрипта !nlu_debug.py:
Меня зовут Настюша
my_name [1.000], greet [0.000], goodbye [0.000],
PERSON=Настюша [1.00], DIETClassifier
Во второй строке идёт список намерений с указанием степени уверенности его классификации, далее - извлечённые сущности (если они есть) со степенями уверенности и имя классификатора, который это сделал.
Возможно пакетное тестирование скриптом !nlu_debug_file.py, который берёт тексты из !nlu_debug_file.txt. Аналогичную информацию предоставляет скрипт !agent.py в процессе диалога. Все эти скрипты находятся в проекте Bot02_PERSON.zip. Для тестирования сущностей средствами RASA проще всего использовать слоты.


Слоты

Слоты являются "ячейками памяти" бота, которые бывают различного типа. В них можно записывать значения сущностей или любую другую информацию. Например, добавим в файл domain.yml раздел slots:

slots:                                      # domain.yml
  PERSON:                                   # имя слота (можно русскими буквами)
    type: text                              # тип слота (это строка текста)
В нём определяется переменная (слот) с именем PERSON. Это имя совпадает с именем сущности PERSON, введенной ранее, поэтому, при извлечении из текста сущности PERSON, её значение запишется в слот PERSON. Если имя слота не совпадает с именем сущности, тогда его заполняют в форме или питоновском действии.

Слоты можно использовать в откликах бота, окружая их имя фигурными скобками:

responses:                                  # domain.yml
  utter_pleased_to_meet_you:
  - text: Рад знакомству, {PERSON}!

Чтобы этот отклик сработал, его надо добавить в историю:

stories:                                    # data/stories.yml
- story: привет, как зовут и пока
  steps:
  - intent: greet                           # 🙎 привет
  - action: utter_greet                     # 💻 Привет, рад встречи  
  - action: utter_what_is_your_name         # 💻 Как тебя зовут?
  
  - intent: my_name                         # 🙎 меня зовут Настя      (из nlu.yml)
  - action: utter_pleased_to_meet_you       # 💻 Рад знакомству, Настя (из domain.yml)

  - intent: goodbye                         # 🙎 до завтра
  - action: utter_goodbye                   # 💻 До скорой встречи!
После обучения (rasa train) можно пообщаться с этим ботом (rasa shell или !agent.py). Подробнее слоты описаны в документе "Слоты и формы", а использование их в историях - в документе "Истории и правила"


Параметры сущностей

При описании сущности можно использовать расширенный синтаксис в фигурных (а не круглых) скобках:

- моя оценка разговора [позитивная]{"entity":"RATING","value":"positive"}
где доступны следующие свойства:
[<entity-text>]{"entity": "<ENTITY_NAME>", 
                "value":  "<ENTITY_VALUE>",
                "role":   "<ENTITY_ROLE>", 
                "group":  "<ENTITY_GROUP>" }


✒ Свойство value удобно использовать для отождествления одинаковых значений сущности, написанный различным образом. Если требуется только свойство value, можно по-прежнему использовать круглые скобки в формате [текст](ENTITY_NAME:value) (пробелов внутри круглых скобок быть не должно). Например:

- intent: number
  examples: |
    - [один]{"entity":"NUMBER",  "value": "1"}
    - [одну](NUMBER:1)
    - [1](NUMBER)                           # значение возмёт из [1]
    - [2](NUMBER)
    - [два](NUMBER:2)
    - [две](NUMBER:2)
В результате, в фразе "Хочу две пиццы" будет выделена сущность NUMBER с value=2, которую дальше можно использовать по значению, забыв про словоформу "две". Желательно подобные фразы с разметкой добавить также в соответствующее намерение:
- intent: i_want_to_buy
  examples: |
  - Хочу [две](NUMBER:1) [пиццы](ITEM:пицца)
  - Буду [пять](NUMBER:5) [пицц](ITEM:пицца)
  - [153]{NUMBER:153}    [пиццы](ITEM:пицца)
RASA будет учиться извлекать сущность NUMBER на обоих намерениях. При наличии опечаток или неучтённых словоформ, извлечённая (правильно) сущность может и не иметь свойства value.


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

- Я хочу полететь из [Днепра]{"entity":"CITY", "role": "from"} 
                   в [Париж]{ "entity":"CITY", "role": "to"}.
Тогда в аналогичной фразе NLU RASA вернёт две сущности с именем CITY. Первую value=Днепра с пометкой role=from, а вторую value=Париж - с пометкой role=to.


✒ Свойство group позволяет указывать номер группы цепочки сущностей (также должно быть в одну строчку):

- Я хочу [маленькую]{"entity": "SIZE",    "group": "1"} 
         [пиццу]{    "entity": "ITEM",    "value": "пицца"}
      с  [грибами]{  "entity": "TOPPING", "group": "1"} и ещё одну
         [большую]{  "entity": "SIZE",    "group": "2"} 
      c  [сыром]{    "entity": "TOPPING", "group": "2"}.
      
- И во вторую добавьте [помидоров]{"entity": "TOPPING", "group": "2"}.


При использовании ролей и групп, их необходимо описывать при объявлении сущности:

entities:
   - CITY:  
       roles:
       - from
       - to
   - TOPPING:
       groups:
       - 1
       - 2

Синонимы и таблицы

Существует ещё один механизм (отличный от value) для отождествления различно написанных сущностей. Например, пусть мы хотим, чтобы на сущность NUMBER в текстах, независимо от склонения (два, две, двух, двумя) всегда возвращалось NUMBER=два. Тогда в data/nlu.yml можно написать:

- synonym: два                              # data/nlu.yml
  examples: |
    - две
    - двух
    - двумя

Чтобы синонимы работали, в конвейере (pipeline) файла config.yml должен находится EntitySynonymMapper.

☝ Сопоставление синонимов происходит только после извлечения сущностей. Это означает, что обучающие примеры должны включать примеры синонимов, чтобы модель научилась распознавать их как сущности и затем заменять на одно и тоже значение. Поэтому механизм synonym полезен только для того, чтобы не указывать каждый раз value в обучающих примерах.


Ещё один способ помочь NLU RASA извлекать сущности, это создание лукап-таблиц (там же в data/nlu.yml).

- lookup: PERSON                            # data/nlu.yml
  examples: |
    - вася
    - петя
    - саша
    - коля
В этом примере сущность PERSON будет гарантированно извлекать приведенные в списке имена. Лукап-таблицы удобны когда список примеров сущностей очень большой. В этом случае они играют роль "базы данных". Однако в лукап-таблице ищется именно тот текст, который приведен в её примерах. При наличии опечаток (в отличии от примеров намерений intent), эта сущность из лукап-таблицы извлечена не будет.

Для работы лукап-таблиц в конвейере нужно добавить:

pipeline:                                   # config.yml
   #...
   - name: RegexFeaturizer
     case_sensitive:    false
     use_lookup_tables: true                # чтобы заработали lookup таблицы
     use_regexes:       true       


Правила

Правила содержат шаблоны коротких кусков диалога, которые всегда должны идти по одному и тому же пути. Правило может включать только одно намерение пользователя (intent), после которого идёт одно или несколько ответов бота (action). Правилами не стоит злоупотреблять, так они являются "необучаемой" частью историй (RASA их только строго выполняет, если они сработали). Чтобы правила работали, в config.yml надо указать:

policies:                                   # config.yml
- name: RulePolicy
Если раздел policies полностью пустой, то этого можно не делать, т.к. в этом случае будут включены политики по умолчанию, в которые RulePolicy входит. Правила перечисляются в файле data/rules.yml. Например:
rules:                                      # data/rules.yml
- rule:  отвечаем на приветствие            # описание содержания правила
  steps:
  - intent: greet                           # 🙎 привет
  - action: utter_greet                     # 💻 Рад встречи с тобой!
Теперь приветствие можно не добавлять в примеры историй, так как оно заложено в "рефлективный ответ" в форме правила. Подробнее правила обсуждаются в документе "Истории и правила".


Регулярные выражения

Регулярные выражения дают дополнительные признаки, для классификации намерений (intent) и извлечения из них сущностей (entity). Для первой задачи используется RegexFeaturizer, который необходимо добавить в конвейер (pipeline) файла config.yml, а для второй RegexEntityExtractor (там же).

Когда в примерах намерений необходимо усилить значимость наличия некоторого текста, создаётся регулярное выражение с произвольным именем:

nlu:
- regex: help_impotent_for_intend           # data/nlu.yml
  examples: |
    - \bпомоги\b                            # наличие слова 'помоги' важно

Имя регулярных выражений для извлечения сущностей RegexEntityExtractor должны совпадать с именем сущности:

nlu:                                        # data/nlu.yml
- regex: ACCOUNT_NUMBER
  examples: |
    - \d{10,12}                             # от 10 до 12 цифр
    
- intent: inform_account_number
  examples: |
    - Мой номер счёта [1234567891](ACCOUNT_NUMBER)
    - Это мой номер счёта: [1234567891](ACCOUNT_NUMBER)

Отметим, что для выделения чисел регулярные выражения не всегда удобны Они помогают извлекать сущность ACCOUNT_NUMBER из любого намерения, не способствуя идентифицировать конкретное намерение intent: inform_account_number. Кроме этого, будет происходить дубляж сущности ACCOUNT_NUMBER двумя классификаторами: RegexEntityExtractor и DIETClassifier. На самом деле, DIETClassifier на приведенных примерах учится выделять любые целые числа, поэтому можно обойтись и без RegexEntityExtractor.


Множественные намерения

Часто люди в чате пишут несколько предложений, каждое из которых может быть самостоятельным намерением (Multi-Intent Classification). Для их обработки в конвейере, в токинезаторе добавляем следующие свойства:

pipeline:                                   # config.yml
   - name: WhitespaceTokenizer
     intent_tokenization_flag: true
     intent_split_symbol: "+"
Затем создаём "объединенное" намерение, имя которого состоит из нескольких уже существующих намерений, соединённых знаком плюс. Много примеров в нём описывать не нужно (?), так как они будут добавлены из соответствующих намерений:
nlu:                                        # data/nlu.yml
- intent: greet+my_name
  examples: |
    - привет. меня зовут [Настя](PERSON)
    - здравствуй. меня зовут [Анастасия](PERSON)
Намерение greet+my_name нужно добавить в раздел intents файла domain.yml и далее использовать в историях или правилах как обычно:
stories:                                    # data/stories.yml
- story: привет, меня зовут
  steps:
  - intent: greet+my_name                   # Привет, меня зовут Маша
  - slot_was_set:
    - PERSON    
  - action: utter_greet                     # Привет.
  - action: utter_glad_to_meet_you          # Рад знакомству, Маша


Диалоги на кнопках

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

Например, попросим клиента в конце общения оценить его качество. Для этого в отклики добавим:

responses:                                  # domain.yml
  utter_how_is_our_conversation:
  - text: "Оцените качество общения:"       # текст предваряющий кнопки
    buttons:
    - title:  отлично                       # текст на первой кнопке
      payload: /perfect                     # /<intent name> намерение клиента
      
    - title:  та такое                      # текст на второй кнопке
      payload: /awful

  utter_happy_for_us:
  - text: Я рад за нас
  utter_so_sorry:
  - text: Я очень сожалею
В поле payload после черты "/" идёт имя намерения которое "распознало" кнопочное меню (оно, как обычно, должно быть добавлено в domain.yml, в раздел intents). В историях можно теперь написать что-то типа:
stories:                                    # data/stories.yml
- story: привет и пока, отлично
  steps:
  - intent: greet                           # 🙎 привет
  - action: utter_greet                     # 💻 Привет, рад встречи  
  - intent: goodbye                         # 🙎 до завтра
  - action: utter_goodbye                   # 💻 До скорой встречи!
  - action: utter_how_is_our_conversation   # Оцените качество общения
  - intent: perfect                         # 🙎 кнопка <отлично>
  - action: utter_happy_for_us              # 💻 Я рад за нас

- story: привет и пока, ужасно
  steps:
  - intent: greet                           # 🙎 привет
  - action: utter_greet                     # 💻 Привет, рад встречи  
  - intent: goodbye                         # 🙎 до завтра
  - action: utter_goodbye                   # 💻 До скорой встречи!
  - action: utter_how_is_our_conversation   # Оцените качество общения
  - intent: awful                           # 🙎 кнопка <та такое>
  - action: utter_so_sorry                  # 💻 Я очень сожалею

Кнопка вместе с намерением может посылать и сущностих:

   buttons:
    - title:   отлично
      payload: /perfect{{"SCORE":"5","INFO":"говорит неплохо"}}
Обратим вниманте на двойные фигурные скобки. В данном случае это механизм экранирования для JSON.
Если кнопочное меню генерится внутри действия как строка "/perfect{\"SCORE\":\"5\"}", то скобки должны быть одинарные.

Естественно, кнопки в консоле выглядят не очень. Однако в продакшене для конкретной среды, всё будет нормально. Например бот для Telegram будет иметь привычный набор кнопок для которых можно задать определённую ориентацию положения. Законченный проект, использующий сочетание текстового ввода и кнопочного меню находится в проекте Bot03_Buttons.zip. Обратим внимание, что для выбора кнопки нужно пользоваться клавишами стрелка вверх-вниз, а не пытаться ввести номер кнопки. Более сложный проект с множеством кнопочных меню (в том числе и динамических) можно загрузить из файла Bot_Pizza_Buttons.zip. Для тестирования !agent.py не подходит и нужно использовать rasa shell.


Тесты

Для проверки работы бота не обязательно каждый раз вводит текст с клавиатуры (в режиме "rasa shell" или !agent.py). Можно в файле tests/test_stories.yml создать набор тестовых историй, прохождение которых будет проверяться в пакетном режиме командой "rasa test". Например, для историй из начала документа тесты могут иметь вид:

stories:                                      # tests/test_stories.yml
- story: 1. привет и пока
    steps:  
    - user: |                                 # что говорит человек
        привет
      intent: greet                           # какое намерение должно распознаваться
    - action: utter_greet                     # какой отклик бота затем ожидается
  
    - user: |                                 
        пока
      intent: goodbye                         
    - action: utter_goodbye                   
#----------------------------------------------------------
- story: 2. пока и привет                     # это ошибочный тест!!!
  steps:
  - user: |
      до свидания!
    intent: goodbye
  - action: utter_greet
Результаты работы команды "rasa test" можно найти в файлах из папки results. В частности, истории которые не прошли тест, находятся в файле failed_test_stories.yml:
stories:                                    # results/failed_test_stories.yml 
- story: 2. пока и привет (.\tests\test_stories.yml)
  steps:
  - intent: goodbye
  - action: utter_greet  # predicted: utter_goodbye
В нашем примере второй тест не прошёл (что указано в комментарии), так как на goodbye мы учили RASA отвечать utter_goodbye, а не utter_greet (как в тесте).

При записи историй необходимо указывать извлекаемые сущности (если это происходит) с правильными значениями, ролями и группами (если им обучали RASA):

  - user: |
      [пять](NUMBER:5) бутылок [фанты](ITEM:фанта)
    intent: number_of_items
Когда в действиях (обычно кастомных на питоне) меняются слоты, то это также следует указывать при помощи slot_was_set (подробнее см. в "Истории и правила").

Если файл results/failed_test_stories.yml после "rasa test" не пуст, и нет комментариев об ошибках прогноза, возможно, указаны неправильные свойства сущностей. Для "отладки", можно начать укорачивать историю, пока она не пройдёт, а затем разбираться где проблема в отрезанном куске.


Кроме файла failed_test_stories.yml стоит просматривать и другие файлы, особенно, достаточно наглядные png-рисунки. Они состоят из трёх групп: намерения (intent_...), сущности (DIETClassifier_...) и истории (story_...). В каждой группе два вида картинок:

Для проверки обучения NLU модуля, можно также запускать скрипт !nlu_stat.py, который показывает к какому намерению отнесен каждый пример и после этого классифицирует все тексты из файла !nlu_stat.txt, помещая результат в файл !nlu_stat.res.


Некоторые полезные команды для обучения и тестирования:


Некоторые проблемы и их устранение


Что дальше

В последующих документах подробнее рассматриваются различные аспекты фреймворка RASA:

Могут пригодиться также следующие внешние ссылки: