habrahabr

Как использовать Томита-парсер в своих проектах. Практический курс

  • воскресенье, 10 августа 2014 г. в 03:10:47
http://habrahabr.ru/company/yandex/blog/225723/

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

В предыдущем посте мы пообещали рассказать, как пользоваться парсером и о синтаксисе его внутреннего языка. Именно этому и посвящен мой сегодняшний рассказ.



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

Грамматика — это набор правил, которые описывают цепочки слов в тексте. Например, если у нас есть предложение «Мне нравится, что вы больны не мной», его можно описать с помощью цепочки [местоимение первого лица, единственного числа], [глагол в настоящем времени и третьем лице], [запятая], [союз] и т.д.



Грамматика пишется на специальном формальном языке. Структурно правило разделяется символом -> на левую и правую части. В левой части стоит один нетермиал, а правая состоит как из терминалов, так и нетерминалов. Терминал в данном контексте — это некий объект, имеющий конкретное, неизменяемое значение. Множество терминалов — алфавит языка Томита, из которого выстраиваются все остальные слова. Терминалами в Томите выступают ‘леммы’ — слова в начальной форме, записанные в одинарных кавычках, части речи (Noun, Verb, Adj…), знаки пунктуации (Comma, Punct, Hyphen…) и некоторые другие спецсимволы (Percent, Dollar…). Всего терминалов в Томите около двадцати, полный их список представлен в нашей документации. Нетерминалы составляются из терминалов, и если проводить аналогию с естественными языками, представляют собой нечто вроде слов. Например, нетерминал NounPhrase, состоящий из двух терминалов Adj и Noun, означает цепочку из двух слов: вначале — прилагательное, потом — существительное.

Чтобы составить нашу первую грамматику, нужно создать файл с расширением .cxx, назовем его first_grammar. Сохранить его можно там же, где лежит сам бинарник парсера. В первой строке файла с грамматикой нужно указать кодировку:

#encoding "utf8"

Далее можно писать правила. В нашей первой грамматике их будет два:

PP -> Prep Noun;
S -> Verb PP;

Первое правило описывает нетерминал PP — предложную группу, состоящую из предлога и существительного (Prep Noun). Второе правило — глагол с предложной группой (Verb PP). В данном случае нетерминал S является корневым, потому что он ни разу не упоминается в правой части правила. Такой нетерминал называется верхушкой дерева. Он описывает целиком ту цепочку, которую мы хотим извлечь из текста.

Наша первая грамматика готова, но прежде чем запускать парсер, нужно проделать ещё несколько манипуляций. Дело в том, что грамматика взаимодействует с парсером не напрямую, а через корневой словарь — сущность, в которой собирается информация обо всех созданных грамматиках, словарях, дополнительных файлах и т.д. Т.е. корневой словарь — это своего рода агрегатор всего, что создается в рамках проекта. Словари для Томита-парсера пишутся с использованием синтаксиса похожего на Google Protobuf (используется модифицированная версия компилятора Protobuf, с поддержкой наследования). Файлам обычно даётся расширение .gzt. Создадим корневой словарь dic.gzt и в начале также укажем кодировку:

encoding "utf8";

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

import "base.proto";
import "article_base.proto";

Далее мы создаём статью. Статья словаря описывает способ выделения цепочки слов в тексте. Грамматика — это один из возможных способов. Цепочку можно выделять при помощи списка ключевых слов, встроенного в парсер алгоритма (цепочки ФИО и дат). Другие способы могут быть добавлены на уровне исходного кода парсера (например, статистический named entity recognizer). Статья состоит из типа, названия и содержания. О том, что такое типы статей и для чего они нужны я расскажу ниже, когда мы будем подробнее говорить о словарях. Пока мы будем использовать базовый тип TAuxDicArticle. Название статьи должно быть уникальным, оно указывается в кавычках после типа. Далее в фигурных скобках перечисляются ключи — содержание статьи. В нашем случае в единственном ключе содержится ссылка на написанную нами грамматику. Сначала мы указываем синтаксис файла, на который мы ссылаемся (в случае файла с грамматикой это всегда tomita), и путь к этому файлу, потом в поле type — тип ключа (его необходимо указывать, если ключ содержит в себе ссылку на грамматику).

TAuxDicArticle "первая_грамматика"
    {
    key = {"tomita:first_grammar.cxx" type=CUSTOM}
    }

Чтобы сообщить парсеру, откуда мы берём исходный текст, куда записываем результат, какие грамматики запускаем, какие факты извлекаем, а также прочую необходимую информацию, создается единый файл конфигурации с расширением .proto. Создадим в папке с парсером файл config.proto. Как обычно, в начале указываем кодировку и переходим к описанию нашей конфигурации.

Единственный обязательный параметр конфигурационного файла — это путь к корневому словарю, который записывается в поле Dictionary. Все остальные параметры опциональны. Информация о входном файле находится в поле Input. Помимо текстовых файлов Томите можно подать на вход папку, архив или stdin. В поле Output записывается, куда и в каком формате (text, xml или protobuf) надо сохранить извлечённые факты. Мы отправим на вход файл input.txt. В поле Articles в перечисляются грамматики, которые мы хотим запустить. Обратите внимание, что здесь мы указываем не сам файл с грамматикой, а название статьи из словаря, которая содержит в себе ссылку на этот файл: как мы уже говорили, парсер взаимодействует со всеми файлами проекта опосредованно через корневой словарь.

encoding "utf8";
    TTextMinerConfig {
    Dictionary = "dic.gzt";


    Input = {File = "input.txt"}

    Output = {File = "output.txt"
              Format = text}

    Articles = [
    { Name = "первая_грамматика" }
    ]
    }

Теперь, когда файл конфигурации готов, нам осталось положить рядом с бинарником файл с текстом для анализа (вы можете воспользоваться нашим тестовым файлом или взять свой) и можно переходить к запуску грамматики. В терминале перейдем в ту папку, где лежит наш парсер. Парсер запускается с единственным аргументом — именем конфигурационного файла. Соответственно, в *NIX-системах команда для запуска будет выглядеть так:

./tomitaparser config.proto

Результаты можно посмотреть в файле output.txt. Однако никаких извлеченных фактов мы там не увидим, потому что в нашей грамматике пока что есть только правила выделения цепочек, а чтобы выделенные цепочки превратились в структурированные факты, надо добавить процедуру интерпретации. О ней мы поговорим чуть ниже. Однако выделенные цепочки мы можем увидеть уже на этом этапе, для этого нужно добавить к файлу конфигурации ещё один параметр — отладочный вывод:

PrettyOutput = "pretty.html"

Благодаря этому параметру результаты работы парсера будут записываться в html-файл с более наглядным представлением. Теперь, если мы перезапустим грамматику и откроем файл pretty.html, который появился в папке, мы увидим, что у нас извлеклись все цепочки, описанные нами в грамматике — глаголы, после которых идет существительное с предлогом:

Результат
ехать на перекладных
остановиться возле духана
остановиться для ночлега
подойти к нему
ехать в Ставрополь
подвигать с помощью
взять на водку
приехать на Линию
деть против горцев
считаться в третьем
последовать за днем
бывать на юге
идти в гору
оглянуться на долину
требовать на водку
посмотреть на штабс-капитана
наткнуться на корову
приютиться у огня
быть в Чечне
отойти за вал
вытащить из чемодана
пожалеть о том
выйти перед фрунт
отдать под суд
быть в гостях
стоять в крепости
поселиться в крепости
ходить на кабана
надорвать со смеха
быть в нем
быть на деньги
хлопать в ладоши
глядеть на эту
бегать за хозяином
стать в сакле
выйти на воздух
ложиться на горы
Пробираться вдоль забора
быть за Тереком
ездить с абреками
прыгать через пни
бежать по следам
повиснуть на передних
полететь в овраг
убиться до смерти
тянуться по степи
бегать по берегу
лететь из-под копыт
сиять во мраке
зазвенеть об кольчугу
удариться об плетень
кинуться в конюшню
схватиться за ружья
вертеться среди толпы
деть в чужом
заговорить о другом
бывать от любви
поскакать в аул
выехать из крепости
перемениться в лице
перескочить через ружье
скакать на лихом
выхватить из чехла
повалиться на землю
прийти в крепость
ускакать на нем
отправиться в аул
пойти к нему
стать в тупик
стать в тупик
сидеть в углу
зачахнуть в неволе
заглянуть в окно
сидеть на лежанке
зайти к нему
ударить по рукам
мочь в щель
грезиться во сне
дождаться у дороги
быть в сумерки
нырнуть из-за куста
видеть с пригорка
продрогнуть на снегу
выйти из сакли
гаснуть по мере
тронуться в путь
выбиваться из сил
вести на небо
пропадать в облаке
отдыхать на вершине
хрустеть под ногами
приливать в голову
отпадать от души
взобраться на Гуд-гору
слезть с облучка
спускаться с Гуд-горы
происходить от слова
проваливаться под ногами
превращаться в лед
скрыться в тумане
биться о решетку
останавливаться в погоду
дать на водку
разыграться на щеках
объявить о смерти
подмывать за кабанами
выйти за крепостной
ходить по комнате
сидеть на кровати
утащить в горы
упасть на постель
быть в сентябре
походить по крепостному
сесть на дерн
быть с вала
сидеть на углу
стоять на месте
привстать на стременах
вернуться с охоты
быть за речкою
биться об заклад
перемениться к этой
проводить на охоте
тосковать по родным
деть в том
выйти из опеки
поехать в Америку
умереть на дороге
бывать в столице
происходить от пьянства
быть за диковинка
шнырять по камышам
уйти в камыши
собраться в кучу
указывать в поле
рваться из-под седла
поравняться с Печориным
приложиться из ружья
упасть на колени
держать на руках
карабкаться на утес
соскочить с лошадей
литься из раны
быть без памяти
посадить к нему
послать за лекарем
выйти из крепости
сесть на камень
потащить в кусты
вскочить на коня
сидеть у постели
отвернуться к стене
хотеться в горы
встретиться с душою
будет в раю
прийти на мысль
умереть в той
стать на колени
пойти на крепостной
умереть с горя
сесть на землю
пробежать по коже
похоронить за крепостью
уехать в Грузию
возвратиться в Россию
расстаться с Максимом


Извлечённые цепочки парсер пытается нормализовать, приводя главное слово цепочки (по умолчанию — первое) в начальную форму.

Следующий шаг — введение процедуры интерпретации, т.е. преобразование извлеченных цепочек в факты.

Сначала нам нужно создать структуру того факта, который мы хотим извлекать, т.е. описать, из каких полей он состоит. Для этого создадим новый файл fact_types.proto. Снова сделаем импорт файлов с базовыми типами, после чего перейдём к описанию самого факта. После слова message пишется название факта, двоеточие и базовый тип факта, от которого наследуется тип нашего факта. Далее в фигурных скобках мы перечисляем поля нашего факта. В нашем случае поле одно, оно обязательное (required), текстовое (string), называется Field1 и мы присваиваем ему идентификатор 1.

import "base.proto";
import "facttypes_base.proto";  
message Fact: NFactType.TFact

    {
        required string Field1 = 1;
    }

Теперь нужно импортировать созданный нами файл в корневой словарь (dic.gzt):

import "fact_types.proto";

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

 S -> Verb interp (Fact.Field1) PP;

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

Последняя деталь, необходимая для запуска — указать в файле конфигурации, какие факты мы хотим извлекать при запуске парсера. Синтаксис в данном случае такой же, как и при указании запускаемых грамматик: в поле Facts квадратных скобках перечисляются все необходимые факты. В нашем случае факт пока только один:

Facts = [
        { Name = "Fact" }
        ]

Теперь можно снова запускать парсер.

Результат
ехать
остановиться
остановиться
подойти
ехать
подвигать
взять
приехать
деть
считаться
последовать
бывать
идти
оглянуться
требовать
посмотреть
наткнуться
приютиться
быть
отойти
вытащить
пожалеть
выйти
отдать
быть
стоять
поселиться
ходить
надорвать
быть
быть
хлопать
глядеть
бегать
стать
выйти
ложиться
Пробираться
быть
ездить
прыгать
бежать
повиснуть
полететь
убиться
тянуться
бегать
лететь
сиять
зазвенеть
удариться
кинуться
схватиться
вертеться
деть
заговорить
бывать
поскакать
выехать
перемениться
перескочить
скакать
выхватить
повалиться
прийти
ускакать
отправиться
пойти
стать
стать
сидеть
зачахнуть
заглянуть
сидеть
зайти
ударить
мочь
грезиться
дождаться
быть
нырнуть
видеть
продрогнуть
выйти
гаснуть
тронуться
выбиваться
вести
пропадать
отдыхать
хрустеть
приливать
отпадать
взобраться
слезть
спускаться
происходить
проваливаться
превращаться
скрыться
биться
останавливаться
дать
разыграться
объявить
подмывать
выйти
ходить
сидеть
утащить
упасть
быть
походить
сесть
быть
сидеть
стоять
привстать
вернуться
быть
биться
перемениться
проводить
тосковать
деть
выйти
поехать
умереть
бывать
происходить
быть
шнырять
уйти
собраться
указывать
рваться
поравняться
приложиться
упасть
держать
карабкаться
соскочить
литься
быть
посадить
послать
выйти
сесть
потащить
вскочить
сидеть
отвернуться
хотеться
встретиться
будет
прийти
умереть
стать
пойти
умереть
сесть
пробежать
похоронить
уехать
возвратиться
расстаться

Дополнительные возможности грамматик


Теперь поставим перед собой более сложную задачу: попробуем написать грамматику, с помощью которой можно извлекать из текста названия улиц. Мы будем искать в тексте дескрипторы (слова улица, шоссе, проспект и т.п.) и анализировать цепочки, которые стоят рядом с ними. Цепочки должны начинаться с заглавной буквы и располагаться слева или справа от дескриптора. Создадим новый файл с грамматикой address.cxx и сохраним его в папке c нашим проектом. Сразу же добавим статью с нашей новой грамматикой в корневой словарь:

TAuxDicArticle "адрес"
{
    key = {"tomita:address.cxx" type=CUSTOM}
}

Теперь добавим в файл fact_types.proto новый факт Street, который мы хотим извлекать. Он будет состоять из двух полей: обязательного (название улицы) и опционального (дескриптор).

message Street: NFactType.TFact
    {
        required string StreetName = 1;
        optional string Descr = 2;
    }

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

Первое понятие — это операторы. Они позволяют получить более удобную сокращенную запись правил грамматики:
  • * — символ повторяется 0 или более раз;
  • + — символ повторяется 1 или более раз;
  • () — символ входит в правило 0 или 1 раз;
  • | — оператор «или».

Перейдём к написанию грамматики. В файле address.cxx напишем два правила — в первом опишем нетерминал StreetW, который будет содержать названия некоторых дескрипторов улиц, а во втором — нетерминал StreetSokr с сокращенными обозначениями.

#encoding "utf8"

StreetW -> 'проспект' | 'проезд' | 'улица' | 'шоссе';
StreetSokr -> 'пр' | 'просп' | 'пр-д' | 'ул' | 'ш';

Далее мы добавим нетерминал StreetDescr, который объединит в себе два предыдущих:

StreetDescr -> StreetW | StreetSokr;

Теперь нам нужно описать цепочки, которые в случае, если они стоят рядом с дескриптором, могут быть названиями улиц. Для этого введём ещё два понятия: пометы-ограничения и согласование.

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

  • Морфологическая помета gram — это поле, которое может быть заполнено любой морфологической категорией из словаря: часть речи, род, число, падеж, время, наклонение, залог, лицо и т.д. <gram = “муж, ед, род”>;
  • Графематические пометы — группа помет, накладывающих ограничение на регистр извлекаемой цепочки: <h-reg1>, <l-reg>;
  • Специальные пометы — могут отражать позицию слова в предложении, алфавит и т.д: <fw>, <mw>, <lat>;
    Почти все пометы поддерживают отрицание: дополнив их тильдой, можно поменять их значение на противоположное: <~fw>, <~lat>.

Несколько особняком стоит синтаксическая помета <rt>. Помеченный ей терминал или нетерминал становится синтаксической вершиной цепочки (главным словом), которая собирается в данном правиле. По умолчанию синтаксической вершиной является первое слово.

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

  • По роду, числу и падежу: gnc-agr;
  • По роду и числу: gn-agr;
  • По падежу: c-agr.

В правиле обязательно нужно писать идентификатор согласования, который указывает, какой символ с каким согласуется. Например, в следующем правиле символ A согласуется с B по роду, числу и падежу, а с C — по числу и падежу:

S -> A<gnc-agr[1], nc-agr[2]> B<gnc-agr[1]> C<nc-agr[2]>;

Вернёмся к нашему правилу, описывающему названия улиц. Обычно название улицы — это либо прилагательное, согласованное с дескриптором (Московский проспект), либо именная группа, стоящая в именительном или родительном падеже (улица Кузнецкий Мост или улица Красных Курсантов). Сначала опишем именную группу. Основным элементом этой цепочки будет слово, перед которым может стоят согласованное с ним прилагательное (улица Вешних Вод), а после — ещё одно слово в родительном падеже (улица Восьмисотлетия Москвы).

StreetNameNoun -> (Adj<gnc-agr[1]>) Word<gnc-agr[1],rt> (Word<gram="род">);

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

StreetNameAdj -> Adj<h-reg1> Adj*;

В корневых правилах соберём вместе дескрипторы и названия улиц и добавим интерпретацию. Будем считать, что название улицы, выраженное именной группой, идёт после дескриптора улицы. Оно должно быть с большой буквы и стоять в именительном или родительном падеже (пометы, приписанные нетерминалу StreetNameNoun будут применяться к главному слову цепочки). Дескриптор улицы у нас интерпретируется в поле Descr факта Street, а название улицы — в поле StreetName.

Street -> StreetDescr interp (Street.Descr) StreetNameNoun<gram="род", h-reg1> interp (Street.StreetName);
Street -> StreetDescr interp (Street.Descr) StreetNameNoun<gram="им", h-reg1> interp (Street.StreetName);

Для шаблона названия улицы, выраженного прилагательным, рассмотрим ещё один частный случай, когда в названии стоит числительное, записанное цифрой (1-я Советская улица). Допустим, у нас нет цепочек такого типа в морфологическом словаре, и парсер не может получить для них морфологические характеристики. В таком случае мы можем описать их с помощью регулярных выражений. Для них в Томите существует отдельный набор помет, которые проверяют соответствие цепочки некоторому шаблону, заданному в поле этих помет: wff проверяет первое слово цепочки, wfl — последнее, а wfm — главное.

S -> A<wff=/[0-9]+.?/>;

Цепочка, которая может встретиться в названии улицы, должна содержать число от 0 до 99, после которого может идти или не идти окончание мужского, женского или среднего рода. Таким образом, у нас получается три правила — по одному на каждый род — которые мы объединим в единое правило NumberW.

NumberW_1 -> AnyWord<wff=/[1-9]?[0-9]-?((ый)|(ий)|(ой)|й)/>; 
NumberW_2 -> AnyWord<wff=/[1-9]?[0-9]-?((ая)|(яя)|(ья)|я)/>; 
NumberW_3 -> AnyWord<wff=/[1-9]?[0-9]-?((ее)|(ье)|(ое)|е)/>; 

NumberW -> NumberW_1 | NumberW_2 | NumberW_3;

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

NumberW_1 -> AnyWord<wff=/[1-9]?[0-9]-?((ый)|(ий)|(ой)|й)/> {outgram="муж,ед,им"};
NumberW_2 -> AnyWord<wff=/[1-9]?[0-9]-?((ая)|(яя)|(ья)|я)/> {outgram="жен,ед,им"};
NumberW_3 -> AnyWord<wff=/[1-9]?[0-9]-?((ее)|(ье)|(ое)|е)/> {outgram="сред,ед,им"};

После этого мы можем написать еще одно правило StreetNameAdj: перед прилагательным может идти числительное NumberW, согласованное с ним в роде, числе и падеже.

StreetNameAdj -> NumberW<gnc-agr[1]> Adj<gnc-agr[1]>;

Наконец, мы можем написать корневые правила для StreetNameAdj. Название улицы, выраженное прилагательным, может стоять перед или после дескриптора и согласовано с ним в роде, числе и падеже. Для сокращённых дескрипторов мы не ставим согласование, т.к. их нет в словаре и мы не знаем их морфологических характеристик.

Street -> StreetNameAdj<gnc-agr[1]> interp (Street.StreetName) StreetW<gnc-agr[1]> interp (Street.Descr);
Street -> StreetNameAdj interp (Street.StreetName) StreetSokr interp (Street.Descr);
Street -> StreetW<gnc-agr[1]> interp (Street.Descr) StreetNameAdj<gnc-agr[1]> interp (Street.StreetName);
Street -> StreetSokr interp (Street.Descr) StreetNameAdj interp (Street.StreetName);

Осталась одна небольшая деталь. Как мы видели раньше, при извлечении и интерпретации цепочек происходит их нормализация (приведение к начальной форме). Это удобно, но в случае с названиями улиц хотелось бы, чтобы этого не происходило, особенно, когда название выражено именной группой. Этого можно добиться, добавив одну небольшую помету в процедуру интерпретации. В скобках после названия поля через двойное двоеточие добавим помету not_norm. Это означает, что цепочка будет появляться в поле факта ровно в том виде, как она была представлена в анализируемом тексте.

Street -> StreetDescr interp (Street.Descr) StreetNameNoun<gram="род", h-reg1> interp (Street.StreetName::not_norm);
Street -> StreetDescr interp (Street.Descr) StreetNameNoun<gram="им", h-reg1> interp (Street.StreetName::not_norm);

Наша грамматика готова, нам осталось только внести изменения в файл конфигурации и указать, что мы будем запускать грамматику address.cxx и пытаться извлечь из текста факт Street. Кроме того, мы будем анализировать другой текст, поэтому заменим файл в поле Input на input1.txt

Input = {File = "input1.txt"}

Output = {File = "output.txt"
    Format = text}

Articles = [
        { Name = "адрес" }
]
Facts = [
        { Name = "Street" }
        ]

Запустим парсер и посмотрим, что у нас получилось:

Результат
Address
StreetName Descr
1-я Дубровская ул
Нижняя Масловка ул
Мира проспект
1-я Владимирская улица
Большая Семеновская ул
Комсомольский просп
Тимура Фрунзе ул
Энтузиастов ш


Словари


Теперь подробнее поговорим о словарях. Файл dic.gzt, который мы создали в самом начале — это словарь, но это некоторая особая сущность, своего рода агрегатор всех файлов в рамках проекта, т.е. скорее служебный файл. Однако словари также могут содержать пользовательские данные. В этом смысле они ближе к словарям в традиционном понимании.

Словари в Томите пишутся в формате протобуф. Статья в газеттире описывает множество цепочек, объединённых общим свойством. Это множество может быть задано в явном виде — лексически — когда мы перечисляем все нужные нам цепочки:

TAuxDicArticle "города"
    { key = "Москва" | "Воронеж" | "Самара"}

или функционально, ссылаясь на грамматику:

TAuxDicArticle "адрес"
    { key = {"tomita:address.cxx" type=CUSTOM}}

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

TAuxDicArticle "организация"
    {
        key = { государственный дума mainword=2 agr=gnc_agr}
    }

В примере выше статья «организация» содержит единственный ключ — цепочку «государственная дума», в которой главное слово второе, и оба слова должны быть согласованы в роде, числе и падеже. Обратите внимание, что все слова в цепочке пишутся в начальной форме.

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

TAuxDicArticle "санкт-петербург"
    {
        key = "санкт-петербург"
        key = "спб"
        key = "питер"
        lemma = "Санкт-Петербург"
    }

Мы уже научились ссылаться на грамматики из словаря, теперь разберемся, как ссылаться на словари из грамматики. Делается это при помощи пометы kwtype. Она означает, что слово, которому соответствует данный (не)терминал, должно быть объектом заданного типа, т.е. входить в статью, указанную в поле пометы kwtype. Есть также помета kwset, при помощи которой можно сослаться сразу на несколько статей. Названия статей перечисляются через запятую в квадратных скобках:

Animals -> Word <kwtype="животные">;
Forest -> Word <kwset=["звери", "птицы"]>;

Ссылаться можно не только на название статьи, но и на её тип. На базовый тип TAuxDicArticle, естественно, ссылаться никакого смысла нет, однако в отдельном файле можно описывать пользовательские типы, которые будут наследоваться от базового. Создадим такой файл и назовем его kwtypes.proto. Как всегда, сначала импортируем туда файлы с базовыми типами:

import "base.proto";
import "articles_base.proto";
import "kwtypes_base.proto";

Затем перейдём к описанию нового типа статей. Это делается достаточно просто. Мы пишем message, название нового типа и после него через двоеточие базовый тип, от которого происходит наследование. Завершаю конструкцию фигурные скобки.

message surname : TAuxDicArticle {}

Теперь попробуем создать словарь с использованием этого типа. Создадим новый .gzt-файл surnames.gzt, в котором перечислим все известные нам фамилии, объединённые общим типом surname. В начале указываем кодировку, затем импортируем файл kwtypes.proto, чтобы парсер знал, откуда брать неизвестные типы статей. Далее уже знакомым нам образом пишем статьи, с той лишь разницей, что вместо названия базового типа TAuxDicArticle мы пишем название созданного нами типа surname:

encoding "utf8";
import "kwtypes.proto";
surname "ленин"
    {
        key = "ленин"
    }

surname "пушкин"
    {
        key = "пушкин"
    }

surname "гагарин"
    {
        key = "гагарин"
    }

Файл нужно сохранить и импортировать его в корневой словарь, добавив строку import "surnames.gzt";. Туда же импортируем файл с описанием новых типов import "kwtypes.proto";.

Теперь, когда у нас есть словарь с фамилиями, мы можем попробовать решить в упрощенном виде одну задачу, которая достаточно часто возникает в Яндексе: выделение конструкций типа «имени кого-то». Создадим файл новой грамматики imeni.cxx. Первое правило у нас будет содержать регулярное выражение, описывающее инициал. Второе правило объединяет два инициала. А третье правило содержит в себе пару инициалов и терминал, который включает в себя все цепочки с типом surname из нашего словаря. Если мы добавим в наш словарь ещё несколько фамилий, то они тоже будут входить в этот терминал. В последнем корневом правиле мы добавили к уже собранной цепочке фамилия + инициалы слово “имени” (в начальной форме).

#encoding "utf8"

Initial -> Word<wff=/[А-Я]\./>;

Initials -> Initial Initial;

FIO -> Initials Word<kwtype=surname>;

Imeni ->'имя'<gram="ед, род"> FIO;

Грамматика готова. Теперь нам осталось добавить статью с новой грамматикой в корневой словарь:

TAuxDicArticle "имени"
    {
        key = {"tomita:imeni.cxx" type=CUSTOM}
    }

В файле конфигурации config.proto можно закомментировать (двойным слэшом в начале строки) список фактов, которые мы хотим извлекать — сейчас они нам не понадобятся. Также укажем, что мы запускаем грамматику imeni.cxx на входном файле input2.txt. Теперь можно запустить парсер. Факты у нас в этот раз не извлекались, так что откроем файл pretty.html и посмотрим, какие цепочки удалось извлечь.

Помимо того, что в грамматиках можно использовать ключевые слова из словарей, в них также могут быть использованы результаты работы других грамматик. Включить одну грамматику в другую можно двумя способами: либо через уже знакомую нам помету kwtype, указав в её поле статью из словаря, ссылающуюся на грамматику, либо при помощи директивы #include. Процедура включения грамматик через #include достаточно проста — она подразумевает, что мы включаем текст одной грамматики в текст другой, их правила абсолютно равноправны и выполняются за один проход. А вот добавление грамматик через kwtype заслуживает отдельного рассмотрения. Для примера используем уже созданную нами грамматику imeni.cxx и включим её в другую грамматику, которая будет извлекать названия организаций. Назовем новую грамматику org.cxx. Для начала перечислим несколько дескрипторов организаций.

#encoding "utf8"

OrgDescr -> 'библиотека' | 'театр' | 'музей';

Во втором правиле мы объединим дескриптор и цепочку «имени кого-то». Для этого мы используем помету kwtype, в поле которой укажем статью «имени», ссылающуюся на грамматику imeni.cxx. Парсеру абсолютно безразлично, что находится в статье, на которую мы ссылаемся в помете kwtype — список лемм или ссылка на грамматику. Он трактует их абсолютно одинаково — как список цепочек.

Наконец, в корневом правиле добавим интерпретацию.

Org_ -> OrgDescr Word<kwtype="имени">;

Org -> Org_ interp (Org.Name);

Добавим нашу новую грамматику в корневой словарь:

TAuxDicArticle "организация"
    {
        key = {"tomita:org.cxx" type=CUSTOM}
    }

Также нужно добавить новый факт в файл fact_types.proto:

message Org: NFactType.TFact
    {
        required string Name = 1;
    }

И, наконец, внесём изменения в конфигурационный файл. В этот раз мы запускаем грамматику, которая лежит в статье с названием “организация”. Факты мы на сей раз извлекаем, поэтому раскомментируем строки и поменяем название факта на “Org”.

Articles = [
        { Name = "организация" }
        ]

Facts = [
        { Name = "Org" }
        ]

Запустим парсер и посмотрим на результаты в файле pretty.html. У нас извлекся факт «Библиотека имени В. И. Ленина».

Что же произошло при включении одной грамматики в другую? Сначала текст «Библиотека им В.И. Ленина» был подан на вход грамматики imeni.cxx. Она превратила его в «Библиотека им_В.И.Ленина», т.е. собрав свою цепочку, она пять токенов превратила в два и в таком виде текст был передан дальше на вход грамматике org.cxx.



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

Все созданные нами файлы и тексты для анализа доступны в архиве по ссылке. Если у вас остались вопросы, связанные с синтаксисом Томита-парсера, вы можете ознакомиться с подробным его описанием в документации.