Извлечение фактов из текста - типичная задача при работе с естественным языком. Ее постоянно решает Яндекс, например, когда выделяет время и место из полученного письма и предлагает внести событие в календарь. Подобные интересные задачи возникают сплошь и рядом в системах, которые напрямую связаны с естественным языком. Поэтому Яндекс разработали свой морфологический парсер и назвали его Томита. Он позволяет выделять факты в тексте, благодаря написанию своих грамматик и словарей. В этом посте мы разберемся как это работает на примере задачи выделения результатов матчей из футбольных новостей.
Для знакомства с Томита-парсером я собрал несколько десятков футбольных новостей из яндексовского 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. Томита-парсер предоставляет возможности для элегантного решения этой непростой проблемы. Тем не менее, все равно крайне сложно написать универсальную грамматику, которая разберется во всевозможных вариантах входных последовательностей. Поэтому работа с естественными языками по-прежнему остается достаточно сложной.
Written on November 11th, 2017 by Alexey Kalina