Работающий программист

Поговори со мной, часть 2: ELIZA

Тэд Ньюард

 

Ted NewardВ нашу прошлую встречу мы успели построить простую систему для реакции на голосовой ввод по телефону, используя Tropo — облачный сервис голосовых и SMS-сообщений. В надежде спастись от неприятностей с дражайшей половиной (из-за того, что в праздники слишком много времени торчали за Xbox) мы дошли до реагирования на голосовой ввод и выбора ответов. К сожалению, некоторым это, возможно, не помогло, и отношения между вами и вашей второй половиной все еще остаются напряженными. И здесь я — как искренне сочувствующий человек — хотел бы предложить свои услуги в качестве психотерапевта-любителя. Однако, как это ни прискорбно, я не слишком хорошо поддаюсь масштабированию и не в состоянии поговорить с каждым из вас индивидуально. Так что вместо этого давайте изучим альтернативу. Я говорю, конечно, об ELIZA.

ELIZA: история

Для тех, кто не знаком с ELIZA, поясню, что это программа «виртуальный собеседник» и один из первых и наиболее известных шагов в направлении создания искусственного интеллекта (artificial intelligence, AI). Написанная еще в 60-е Джозефом Вейценбаумом (Joseph Weizenbaum) на языке Lisp, ELIZA — сравнительно простой (по нынешним меркам) процессор входных фраз и ответов (input-response processor), который выделяет ключевые слова во входной фразе пользователя, а затем генерирует ответы на естественном языке, опираясь на эти ключевые слова. Если бы вы, например, сказали Элизе: «Мне грустно», то она могла бы ответить: «Почему вам грустно?», «Вам грустно потому, что вы разговариваете со мной?» или даже «Перестаньте грустить!». По сути, ответы могли быть настолько подходящими, что какое-то время считалось, что, пойдя по этому пути, можно было бы создать программу, способную выдержать тест Тьюринга.

Однако спустя четыре десятилетия мы все еще не имеем возможности беседовать со своими компьютерами так, как это воображал Артур Кларк в своем романе «Космическая одиссея 2001 года», но это не означает, что мы должны игнорировать «человекоподобное» общение в наших программах. Мы становимся свидетелями того, как обработка естественных языков (natural language processing, NLP) потихоньку встраивается в компьютеризованные системы, и в сочетании с механизмами синтеза и распознавания речи перед нами открываются совершенно новые горизонты во взаимодействии человека с машиной. Например, даже простая система распознавания ключевых слов, подобная ELIZA, может оказаться полезной в попытках создать системы голосовой помощи на веб-сайтах или в перенаправлении клиентов в нужные им отделы крупной корпорации без вводящего в ступор дерева вызовов по типу «Нажмите 1, если вам нужен отдел по работе с клиентами, нажмите 2, если вам нужен отдел кадров, нажмите 3, если вам нужен…».

Но если вы никогда не имели дело даже с простейшей NLP, попытки работы с чем-то подобным могут слегка обескуражить. К счастью, можно получить неплохие результаты даже при самых поверхностных усилиях. По ссылке bit.ly/uzBSM9 есть отличное учебное пособие, которое послужит нам источником вдохновения в реализации ELIZA на F#. Выбор F# в данном случае определяется двумя причинами: во-первых, это дань использованию Lisp в оригинальной ELIZA, потому что и F#, и Lisp являются языками функционального программирования, а во-вторых, это хороший пример программы на F#. Естественно, мы назовем ее F#-Eliza, или сокращенно Feliza (это звучит экзотичнее), и реализуем ее как F#-библиотеку, чтобы ее можно было встраивать во множество других программ.

Feliza: версия 0

Интерфейс Feliza должен быть лаконичным, приятным и простым — и скрывать всю внутреннюю сложность этой программы. В каталоге классических шаблонов «Банды Четырех» (Gang-of-Four) это шаблон Facade, и F# упрощает создание Facade за счет своей «модульной» функциональности:

module Feliza
open System
let respond input =  "Hi, I'm Feliza"

Например, при использовании Feliza в программе консольного режима это выглядело бы так:

open Feliza
open System
let main =
  Console.WriteLine("Hello!")
  while true do
    Console.Write("> ")
      let input = Console.ReadLine()
      let responseText = respond input
      Console.WriteLine(responseText)
      if (input.ToLower().Equals("bye")) then
        Environment.Exit(0)
  ()

{Для верстки: в следующем абзаце есть немецкая буква с акцентом, не потеряйте ее.}

Тем самым мы получаем своего рода тестовый стенд. Это также облегчает встраивание Feliza в другие среды, если мы сможем придерживаться этого über-простого API.

Кстати, когда мы приступим к созданию рабочих реализаций, не забудьте, что Feliza не предназначена на роль универсального NLP-механизма — это потребовало бы гораздо больше работы и места, которого у меня нет в этой рубрике. И вообще, проблематикой NLP в Microsoft Research занимается целое подразделение (подробнее о том, что исследуют в этом подразделении, см. по ссылке research.microsoft.com/groups/nlp). А я не работаю в этом подразделении.

Feliza: версия 1

Самый простой способ получить первую «рабочую» версию Feliza — создать список возможных ответов и осуществлять случайный выбор из них:

let respond input =
  let rand = new Random()
  let responseBase =
    [| "I heard you!";
      "Hmm. I'm not sure I know what you mean.";
      "Continue, I'm listening...";
      "Very interesting.";
      "Tell me more..."
    |]
  responseBase.[rand.Next(responseBase.Length - 1)]

Работая здесь с массивом, мы несколько отклоняемся от стиля F#, но это действительно облегчает случайный выбор из набора возможных ответов. Беседа получится не особо интересной, но наверняка знакомой тем, кто пытался поговорить с программистом, который в этот момент пишет какой-то код. Тем не менее, в течение какого-то небольшого времени это может и впрямь показаться настоящим разговором. Однако мы сделаем получше.

Feliza: версия 2

Следующая очевидная реализация — создать «базу знаний» с готовыми ответами на конкретные вопросы пользователя. Это легко моделируется в F# с помощью tuple-типов, где первым элементом является входная фраза, на которую мы хотим отреагировать, а вторым — собственно ответ. Или, чтобы проявить больше человечности (и избежать очевидных повторов в ответах), можно сделать второй элемент списком возможных ответов и выбирать один из них случайным образом, как показано на рис. 1.

Рис. 1. База знаний в Feliza

let knowledgeBase =
  [
    ( "Bye",
      [ "So long! Thanks for chatting!";
        "Please come back soon, I enjoyed talking with you";
        "Eh, I didn't like you anyway" ] );
    ( "What is your name", 
      [ "My name is Feliza";
        "You can call me Feliza";
        "Who's asking?" ] );
    ( "Hi",
      [ "Hi there";
        "Hello!";
        "Hi yourself" ] );
    ( "How are you",
      [ "I'm fine, how are you?";
        "Just peachy";
        "I've been better" ] );
    ( "Who are you",
      [ "I'm an artificial intelligence";
        "I'm a collection of silicon chips";
        "That is a very good question" ] );
    ( "Are you intelligent",
      [ "But of course!";
        "What a stupid question!";
        "That depends on who's asking." ] );
    ( "Are you real",
      [ "Does that question really matter all that much?";
        "Do I seem real to you?";
        "Are you?" ] );
    ( "Open the pod bay doors",
      [ "Um... No.";
        "My name isn't HAL, you dork.";
        "I don't know... That didn't work so well last time." ] );
  ]
let unknownResponses =
  [ "I'm sorry, could you repeat that again?";
    "Wait, what?";
    "Huh?" ]
let randomResponse list =
  let listLength list = (List.toArray list).Length
  List.nth list (rand.Next(listLength list))
let cleanInput (incoming : string) =
  incoming.
    Replace(".", "").
    Replace(",", "").
    Replace("?", "").
    Replace("!", "").
    ToLower()
let lookup =
  (List.tryFind
    (fun (it : string * string list) ->
      (fst it).Equals(cleanInput input))
    knowledgeBase)
randomResponse (if Option.isSome lookup then
                    (snd (Option.get lookup))
                else
                    unknownResponses)

В этой версии выполняется довольно простая очистка вводной фразы и осуществляется поиск совпадения с первой частью списка tuple-значений («базы знаний»), затем из списка случайным образом выбирается ответ. Если в базе знаний не найдена «ключевая фраза», генерируется ответ на непонятный вопрос, вновь случайным образом выбираемый из списка. (Согласен, очистка выполняется особо неэффективным способом, но, когда дело доходит до взаимодействия с человеком, задержки не составляют проблемы. По сути, в некоторых реализациях программ — виртуальных собеседников время ответа умышленно замедляется, и он выводится посимвольно, имитируя набор на клавиатуре.)

Очевидно, поскольку входная фраза должна иметь точное совпадение для инициации ответа, на практике нам понадобилась бы гораздо более обширная база знаний, включающая все возможные отклонения в человеческой речи. Более того, мы многое теряем, когда Feliza не отвечает на пользовательский ввод более осмысленным образом. Одна из сильных сторон ELIZA была в том, что, если вы говорили: «Я люблю картошку», она могла ответить «Картошка важна для вас?», делая беседу более «персонифицированной».

Feliza: версия 3

Эта версия будет сложнее, зато более гибкой и мощной. По сути, мы превратим алгоритм поиска точного совпадения в гибкий алгоритм, преобразовав список tuple-значений в список функций, каждая из которых оценивается и получает шанс на формирование ответа. Это открывает огромные возможности в том, как Feliza сможет взаимодействовать с входной информацией и как она будет выбирать слова из ввода для генерации конкретных ответов.

Программа начинается с простого списка «правил обработки», в нижней части которого располагается правило перехвата, указывающее, что программа не знает, как ответить, — это эквивалент списка unknownResponses из предыдущей версии (рис. 2).

Рис. 2. Правило перехвата

let processingRules =
  [
   // ...
    // Правило перехвата (catchall rule) на случай, когда
    // не найдено ни одно совпадение до этой точки;
    // считайте это универсальным трафаретом. Этот случай
    // всегда должен рассматриваться в последнюю очередь!
    (
      (fun (it : string) ->
        Some(randomResponse
          [
          "That didn't make sense.";
          "You cut out for a second there. What did you say?";
          "Wait--the Seahawks are about to... Never mind. They lost.";
          "I'm sorry, could you repeat that again?";
          "Wait, what?";
          "Huh?"
          ]))
    )
  ]
List.head (List.choose (fun (it) -> it (cleanInput input)) processingRules)

Главное в этой версии — последняя строка, где функция List.choose принимает каждый processingRule и выполняет его применительно к вводу; если processingRule возвращает значение Some, это значение добавляется в список, возвращаемый вызвавшему коду. Теперь мы можем добавлять новые правила, каждое из которых возвращает некое значение, а затем либо принимать первое из них (как на рис. 2, используя List.head), либо случайным образом выбирать одно из них. В будущей версии мы могли бы возвращать значение Some, которое одновременно является и текстовым ответом, и весовой долей соответствия, что помогло бы при выборе правильного ответа.

Писать правила теперь становится легче. У нас может быть правило, которое просто выделяет ключевые слова из ввода с помощью поиска по шаблону в F#:

(fun (it : string) ->
  match it with
  | "Hi" | "Howdy" | "Greetings" ->
    Some(randomResponse
      [
      "Hello there yourself!";
      "Greetings and salutations!";
      "Who goes there?"
      ])
  | _ -> None
);

Или можно использовать сокращенную конструкцию function для выполнения той же задачи, как показано на рис. 3.

Рис. 3. Конструкция function

(function
  | "How are you?" ->
    Some(randomResponse
      [
      "I'm fine, how are you?";
      "Just peachy";
      "I've been better"
      ])
  | "open the pod bay doors" ->
    Some(randomResponse
      [
      "Um ... No.";
      "My name isn't HAL, you dork.";
      "I don't know ... That didn't work so well last time."
            ])
  | _ -> None
);

Однако большую часть времени Feliza не будет получать эти готовые фразы, поэтому лучше сделать так, чтобы она извлекала опорную информацию из ключевых слов в пользовательском вводе, как на рис. 4.

Рис. 4. Связывание ответов с ключевыми словами

(fun (it : string) ->
  if it.Contains("hate") || it.Contains("despise") then
    Some(randomResponse
      [ "Why do you feel so strongly about this?";
        "Filled with hate you are, young one.";
        "Has this always bothered you so much?" ])
  else
    None
);
(fun (it : string) ->
  if it.StartsWith("what is your") then
    let subject =
      it.Substring(it.IndexOf("what is your") +
                         "what is your".Length).Trim()
    match subject with
      | "name" ->
        Some(randomResponse
          [ "Feliza."; "Feliza. What's yours?";
            "Names are labels. Why are they so important to you?" ])
      | "age" ->
        Some(randomResponse
          [ "Way too young for you, old man."; "Pervert!";
            "I was born on December 6th, 2011" ])
      | "quest" ->
        Some("To find the Holy Grail!")
      | "favorite color" ->
        Some("It's sort of green but more dimensions")
      | _ ->
        Some("Enough about me. What's yours?")
  else
    None
);

Ветераны программирования на F# заметят, что активные шаблоны F# (active patterns) прекрасно подошли бы в ряде случаев; тот, кто не знаком с конструкцией активных шаблонов в F# (или вообще с синтаксисом F# поиска по шаблону), может узнать все детали из великолепной статьи Джессики Керр (Jessica Kerr) на эту тему (эта статья состоит из двух частей, и их можно найти по ссылкам bit.ly/ys4jto и bit.ly/ABQkSN).

В следующий раз: подключение к Feliza

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

Удачи в кодировании!


Тэд Ньюард (Ted Neward) — консультант по архитектуре в компании Neudesic LLC. Автор и соавтор многочисленных книг, в том числе «Professional F# 2.0» (Wrox, 2010), более сотни статей, часто выступает на многих конференциях по всему миру; кроме того, имеет звание Microsoft MVP в области C#. С ним можно связаться по адресу ted@tedneward.com, если вы заинтересованы в сотрудничестве с его группой. Также читайте его блог blogs.tedneward.com.

Выражаю благодарность за рецензирование статьи эксперту Мэтью Подвысоки (Matthew Podwysocki).