Калина Алексей блог программиста

Извлечение фактов с Томита-парсер

Извлечение фактов из текста - типичная задача при работе с естественным языком. Ее постоянно решает Яндекс, например, когда выделяет время и место из полученного письма и предлагает внести событие в календарь. Подобные интересные задачи возникают сплошь и рядом в системах, которые напрямую связаны с естественным языком. Поэтому Яндекс разработали свой морфологический парсер и назвали его Томита. Он позволяет выделять факты в тексте, благодаря написанию своих грамматик и словарей. В этом посте мы разберемся как это работает на примере задачи выделения результатов матчей из футбольных новостей.

Задача

Для знакомства с Томита-парсером я собрал несколько десятков футбольных новостей из яндексовского RSS. Среди них есть как информация об околофутбольных событиях (например, трансферы или интервью со спортсменами), так и новости о результатах матчей. Вот небольшая вырезка из RSS:

<item>
    <title>Бельгия и Мексика сыграли вничью, Лукаку и Лосано сделали по дублю</title>
    <description>Польша и Уругвай сыграли вничью в товарищеском матче - 0:0. В еще одном поединке Бельгия и Мексика также не смогли выявить победителя - 3:3.</description>
    <pubDate>11 Nov 2017 00:43:00 +0300</pubDate>
    <!-- мета-информация -->
</item>
<item>
    <title>Сборная Сенегала вышла в финальную стадию ЧМ-2018</title>
    <description>Сборная Сенегала досрочно обеспечила себе путевку в финальную стадию чемпионата мира по футболу, который пройдет в России летом 2018 года.</description>
    <pubDate>10 Nov 2017 22:29:10 +0300</pubDate>
    <!-- мета-информация -->
</item>

Простым xslt я выделил все описания новостей (поле Description) в отдельный текстовый файл. Наша цель: выделить из всего множества статей только факты, содержащие названия двух команд и счет.

Томита-Парсер

Сразу скажу, что на сайте Яндекса есть очень подробная документация, а также короткий видеокурс, благодаря которому меньше чем за час можно разобраться в основных принципах работы парсера.

В парсере имеется три основных понятия:

Эти сущности описываются в разных файлах и мы постепенно будем их создавать. Начнем со словаря.

Газеттир

Для описания словаря нужно создать файл с расширением gzt. Этот файл является обязательным и без него парсер работать не будет (другим таким файлом является конфигурационный, но о нем несколько позже). Во-первых, все файлы, где используется русский текст, необходимо начинать с явного определения кодировки utf8. Далее необходимо импортировать служебные файлы. После того как мы создадим файлы со своими типами и фактами, мы импортируем и их.

encoding "utf8";
import "base.proto";     
import "articles_base.proto";

Теперь мы создадим несколько так называемых статей, в которых определим наборы глаголов, описывающих результаты матчей. Для этого введем новый тип статей - result_verb. Поле key определяет какие глаголы входят в статью. Поле lemma позволяет заменить найденную цепочку, на ту, которая указана в этом поле.

result_verb "победа"
{
    key = "победить" | "выиграть" | "разгромить" | "одолеть";
    lemma = "победа"
}
result_verb "поражение"
{
    key = "проиграть" | "уступить";
    lemma = "поражение"
}
result_verb "ничья"
{
    key = "сыграли вничью" | "разошлись миром" | "не смогли выявить победителя";
    lemma = "ничья"
}

Для определения новых типов статей нужно создать новый файл. В нем после служебных импортов с помощью ключевого слова message указываются новые типы. При этом они должны наследоваться от базового типа TAuxDicArticle. Код файла kwtypes_football.proto:

import "base.proto";     
import "articles_base.proto";
message result_verb : TAuxDicArticle {}

После создания файла его нужно импортировать в наш словарь:

import "kwtypes_my.proto";

Теперь перейдем к созданию грамматики.

Грамматика

Правила

Создадим еще один файл - football_result.cxx. В нем мы опишем набор правил, используемых для распознавания цепочек. Правила состоят из левой и правой частей, разделенных символом ->. В левой части всегда стоит один нетерминал. В правой - последовательность терминалов и нетерминалов. В роли терминалов выступают либо конкретные леммы, заключенные в кавычки, либо предопределенные парсером ключевые слова. Например: Noun (существительное), Word (любое слово), Punct (точка) и др.

Пометы

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

Операторы

Еще одна ключевая возможность при написании правил заключается в применении операторов к не(терминалам). Оператор | позволяет указывать множественный выбор в правой части правил, * означает, что символ встречается ноль или более раз, а оператор + означает появление символа один или более раз. Теперь нам хватит теории, чтобы описать свою грамматику для распознавания цепочек с результатами матчей.

Реализация

Первоначально создадим правила для распознавания названий команд:

National -> Noun<gram="geo"> | Noun<c-agr[1]> Noun<gram="geo", c-agr[1]>;
Club -> Noun<h-reg1, quoted>;
Club -> Word<h-reg1, l-quoted> Word* Word<r-quoted>;
Team -> Club | National;

Первое правило относится к национальным сборным. В новостях их, как правило, записывают в одном из двух форматов, на примере нашей Родины: либо Россия, либо сборная России. Соответственно, мы ищем слова, являющиеся географическими объектами и пары, которые согласуются по падежу. Второе и третье правило относятся к клубам. Названия клубов пишут в кавычках и с большой буквы, при этом они могут состоять из нескольких слов. Нетерминал Team будем использовать для распознавания всех типов команд. Теперь добавим правило для глагола, из нашего словаря:

Result -> Verb<kwtype="result_verb">;

Кроме того, нам нужен нетерминал для распознавания счета. Для этого будем использовать регулярное выражение:

Score -> AnyWord<wff=/[0-9]:[0-9]/>;

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

S -> Team AnyWord* Result AnyWord* Team AnyWord* Score;
S -> Team AnyWord* Team AnyWord* Result AnyWord* Score;
S -> Team AnyWord* Team AnyWord* Score;

После создания грамматики нужно сослаться на нее в словаре:

TAuxDicArticle "Результат"
{
    key = { "tomita:football_result.cxx" type=CUSTOM }
}

Факты

Теперь мы можем находить цепочки слов в тексте, но мы еще не научились выделять из них важную для нас информацию. Для этого нужно создать еще один файл - facttypes.proto. В нем мы определим новый тип фактов ResultFact и унаследуем его от NFactType.TFact. Среди атрибутов факта будут использоваться названия команд, счет и слово, описывающее результат (для демонстрации работы нормализации обнаруженных лемм). Атрибуты могут быть обязательными и опциональными:

import "base.proto";
import "facttypes_base.proto";
message ResultFact: NFactType.TFact 
{
    required string FirstTeam = 1;
    optional string Result = 2;
    required string SecondTeam = 3;
    optional string Score = 4;
}

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

Result -> Verb<kwtype="result_verb"> interp(ResultFact.Result::norm="nom,sg");
Score -> AnyWord<wff=/[0-9]:[0-9]/> interp(ResultFact.Score);
S -> Team interp(ResultFact.FirstTeam) AnyWord* Result AnyWord* Team interp(ResultFact.SecondTeam) AnyWord* Score;
S -> Team interp(ResultFact.FirstTeam) AnyWord* Team interp(ResultFact.SecondTeam) AnyWord* Result AnyWord* Score;
S -> Team interp(ResultFact.FirstTeam) AnyWord* Team interp(ResultFact.SecondTeam) AnyWord* Score;

Файл конфигураций

Без этого файла работа парсера невозможна. Он содержит информацию обо всех необходимых сущностях и передается программе как единственный аргумент. В полях конфига указываются словарь, грамматика и факты. Также можно определить отличные от stdin и stdout вход и выход. С помощью PrettyOutput генерируется html-файл с удобным для чтения форматом вывода совпавших цепочек и обнаруженных фактов.

encoding "utf8";
TTextMinerConfig {
  Dictionary = "dic.gzt";  
  PrettyOutput = "results.html";
  Input = {
    File = "articles.txt";
  }
  Articles = [
    { Name = "Результат" }
  ]
  Facts = [
    { Name = "ResultFact" }
  ]
  Output = {
    Format = text;
  }
}

Результаты

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

Сборная Бразилии в товарищеском матче победила Японию – 3:1. По голу у южноамериканцев забили Неймар, Марселу и Габриэл Жезус.
Хозяева одержали победу со счётом 2:0. «Трёхцветные» открыли счёт на 18-й минуте, когда точным ударом отметился Антуан Гризманн.
Польша и Уругвай сыграли вничью в товарищеском матче - 0:0. В еще одном поединке Бельгия и Мексика также не смогли выявить победителя - 3:3.
Напомним, 2 ноября «Марсель» на выезде играл против португальской «Виктории Гимарайнш» (0:1). Во время разминки перед матчем в адрес Эвра звучали оскорбления от болельщиков «Марселя».
Сборная России по футболу проиграла команде Аргентины в товарищеском матче (0:1). Единственный гол в матче забил форвард «Манчестер Сити» Серхио Агуэро.
Сегодня футболисты саратовского «Сокола» уступили в Красногорске (Московская область) «Зоркому» 1:5.
Юношеская сборная России (U19) проиграла сверстникам из Румынии со счетом 1:2 в матче 1-го квалификационного раунда Евро-2018.
Глушаков дебютировал в сборной России 29 марта 2011 года в товарищеском матче с Катаром (1:1).

А вот такие факты были обнаружены и отображены в файле results.html:

ResultFact
FirstTeam Result SecondTeam Score
сборная Бразилии победа Япония 3:1
польша ничья Уругвай 0:0
Бельгия   Мексика 3:3
Марсель   Виктория 0:1
сборная России поражение Аргентина 0:1
Сокол поражение Красногорск 1:5
сборная России поражение Румыния 1:2
сборная России   Катар 1:1

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

Заключение

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