Время прочтения: 10 мин.

Большая часть данных в мире не структурирована – это просто тексты на русском или на любом другом языке. Извлеченные факты из таких текстов могут представлять особый интерес для бизнеса, поэтому подобные задачи возникают сплошь и рядом. Этим вопросом занимается отдельное направление искусственного интеллекта: обработка естественного языка, тот самый NLP (Natural Language Processing).

Существует много способов выделить факты из текста и у всех свои плюсы и минусы:

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

Высокая скорость работы и стабильность нивелируется сложностью синтаксиса и низким покрытием.

  • Нейронные сети

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

  • КС-грамматики

Предсказуемость результата, легко писать правила, но сложно запускать в ПРОМе

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

Исходный текст
Оплата за аренду торгового места №2 по адресу ул Маршала Жукова,15 за июнь 2020г., в сумме 5400,00 р
Перечисление денежных средств на Шустрик У.С. в субаренду автомобиля в сумме 15299,00 руб, без НДС
Оплата за найм общежития №575 по адресу пр Обуховской обороны, 145 от 17.09.2020 за февраль 2020 года, с ндс 18% — 5300 рублей.
Оплата за аренду нежилого помещения по адресу Малая Садовая ул, 23, договор 51 от 01.09.2020 — 7500 рублей.
Частичная оплата по Договору аренды №1-03а от 01.07.2020 за аренду помещения по проспекту Дальневосточный 211 в октябре 2020 Сумма 23000 в т.ч.НДС(18%)

Что такое Томита-парсер?

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

Где можно использовать Томита-парсер?

  • Обработка транзакций – аренда, покупка
  • Обработка транскрипций звонков – выставление задач
  • Новостной мониторинг – оценка состояния кредитующейся компании
  • Парсинг текста резюме – автоматизация выделения навыка и опытов кандидата
  • Парсинг текста судебных дел

Как работает Томита-парсер?

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

Для запуска необходима сама программа tomitaparser.exe (рекомендации по сборке см. здесь) и следующие файлы:

  • config.proto — конфигурационный файл парсера. Сообщает парсеру всю информацию о том, где искать все остальные файлы и как их интерпретировать. Этот файл обязательный и выступает в роли единственного аргумента для tomitaparser.exe;
  • dic.gzt – корневой словарь. Содержит перечень всех используемых в проекте словарей и грамматик. Другими словами, это некий агрегатор, который собирает все, что создается в рамках проекта. Без этого файла парсер работать не будет;
  • mygram.cxx – грамматика. Содержит набор правил, которые описывают текстовые цепочки. Таких файлов может быть несколько. Взаимодействует с парсером через корневой словарь;
  • facttypes.proto – описание типов фактов;
  • kwtypes.proto – описание типов ключевых слов. Нужен, если в проекте создаются новые типы ключевых слов.

Все эти файлы необходимо начинать с явного указания кодировки utf8 везде, где планируется использовать кириллические символы (русский текст).

Корневой словарь

Начинаем с создания корневого словаря «dic.gzt», где выполняем импорт служебных файлов и грамматики, которую мы создадим чуть позже.

encoding "utf8"; // явно указываем кодировку

// импортируем зашитые в парсер файлы с базовыми типами, используемыми в словарях и грамматиках
import "base.proto";
import "articles_base.proto";

// оставляем ссылку на нашу грамматику
TAuxDicArticle "payment" {
	key = { "tomita:mygram.cxx" type=CUSTOM }
};

Грамматика

Для создания своей грамматики разберемся с простейшими правилами и понятиями. Томитные грамматики работают с цепочками, где грамматика — это набор правил, которые описывают цепочки слов в тексте. Грамматика пишется на специальном формальном языке. Структурно правило разделяется символом «->» на левую и правую части. В левой части указывается один терминал, в правой – последовательность терминалов и нетерминалов. Нетерминал строится из терминалов и должен хотя бы один раз встретиться в правой части правила. Если нетерминал встречается только в левой части это означает вершину грамматики. В роли терминалов выступают названия частей речи (Noun, Verb, Adj), символы (Comma, Punct, Ampersand, PlusSign) и леммы. Полный перечень терминалов см. по ссылке.  

Правая часть правила может сопровождаться операторами. Например, оператор « после (не)терминала означает, что символ повторяется один или более раз. Этот и другие операторы подробно описаны в документации.

Для наложения ограничений на (не)терминал используются специальные пометы, которые уточняют свойства (не)терминала, например, определение регистра символов или связей по роду и падежу между словами. Записываются пометы после (не)терминала в угловых скобках «< >» через запятую. С полным перечнем ограничений-помет можно ознакомится по ссылке. Теперь, когда мы обладаем необходимым теоретическим минимумом создадим в папке с парсером файл формата «cxx», где мы будем описывать свою грамматику – «mygram.cxx». Ссылку на этот файл мы уже оставили в корневом словаре. Для начала создадим правило для выделения назначения платежа. В нашем случае наименование оплаченного объекта — это существительное, перед которым может стоять прилагательное, стоящее за словами «аренда», «субаренда», «найм».

#encoding "utf8" // явно указываем кодировку

// оператор "|" работает аналогично оператору "или"
Rent -> 'аренда' | 'субаренда' | 'найм';

// оператор "*" означает, что символ может встречаться в тексте 0 или более раз
// помета <gnc-agr[1]> говорит о том, что прилагательное должно быть согласовано с существительным по роду, числу и падежу
Purpose -> Rent Adj<gnc-agr[1]>* Noun<gnc-agr[1]>;

Далее нам нужен нетерминал для распознавания адреса. Как правило, название улиц состоит из прилагательного согласованного с дескриптором улицы, например, Московский проспект. Или это может быть именная группа, например,улица Красных зорь.

// в нетерминале StreetW указываем названия дескрипторов улицы, а в StreetAbbr - перечисляем известные сокращения
StreetW -> 'улица' | 'проспект' | 'шоссе' | 'линия';
StreetAbbr -> 'ул' | 'пр' | 'просп' | 'пр-т' | 'ш';

// объединяем два нетерминала в один нетерминал StreetDescr, который будет обозначать либо полнозначную лемму StreetW либо сокращение StreetAbbr
StreetDescr -> StreetW | StreetAbbr;

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

Нетерминалом «StreetNameNoun» мы описали названия улиц, выраженных существительным. Основным элементом в данной цепочке выступает слово, для этого, обозначаем его пометой «<rt>». Перед ним опционально может стоять или не стоять прилагательное, согласованное по роду, числу и падежу. После основного слова может стоять или не стоять слово в родительном падеже, например, пр. Обуховской обороны. Чтобы указать на то, что прилагательное и слово в родительном падеже слева и справа от основного текста являются опциональными, т.е. не обязательными, используем оператор «()». Нетерминал «StreetNameAdj» описывает названия улиц, выраженных прилагательным. Первое прилагательное в такой цепочке начинается с большой буквы. Добиваемся этого результата благодаря помете «<h-reg1>». Далее может встречаться еще некоторое количество прилагательных, для этого применяем оператор «*». Переходим к описанию правил, определяющих адрес.

Address -> StreetDescr StreetNameNoun<gram="род", h-reg1>;
Address -> StreetDescr StreetNameNoun<gram="им", h-reg1>;

Address -> StreetNameAdj<gnc-agr[1]> StreetW<gnc-agr[1]>;
Address -> StreetNameAdj StreetAbbr;

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

// добавляем список месяцев в корневой словарь «dic.gzt»
TAuxDicArticle "month" {
	key = { "январь" | "февраль" | "март" | "апрель" | "май" | "июнь" | "июль" | "август" | "сентябрь" | "октябрь" | "ноябрь" | "декабрь" }
};

В файл с грамматикой добавляем следующее:

Month -> Noun<kwtype="month">;
Year -> AnyWord<wff=/[1-2]?[0-9]{1,3}г?\.?/>;

Period -> Month Year;

Пометка «kwtype» означает, что существительное ограничено статьей «month» в корневом словаре, а благодаря регулярным выражениям мы выделяем год как число от 0 до 2999 с возможными «г» или «г.» в конце. Переходим к определению корневого нетерминала, который соберет вместе все созданные ранее правила. Корневой нетерминал назовем «Result» и составим несколько возможных вариантов:

Result -> Purpose AnyWord* Address AnyWord* Period;
Result -> Purpose AnyWord* Address;
Result -> Purpose;

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

Факты

На данном этапе мы научили парсер выделять цепочки слов в тексте. Для извлечения фактов из полученных цепочек создаем отдельный файл – «facttypes.proto» и сразу же добавляем в корневой словарь «dic.gzt» строчку с импортом (помним, что корневой словарь- это агрегатор всего, что создается в проекте).

import "facttypes.proto"; // импортируем в словарь «dic.gzt» факты

В файле «facttypes.proto» определяем новый факт «Payment» и добавляем три атрибута (поля): назначение, адрес и период платежа. Запишем в файл следующее:

// импорт базовых типов
import "base.proto";
import "facttypes_base.proto";

message Payment: NFactType.TFact {
	required string Purpose = 1;
	optional string Address = 2;
	optional string Period = 3;
};

Факт «Payment» наследуется от базового типа «NFactType.TFact», а «required» и «optional» означает, что атрибут является обязательными или опциональным соответственно. Для того, чтобы интерпретировать подцепочку в факт, необходимо написать слово «interp» и после него в скобках указать имя факта и имя поля, в которое должна попасть подцепочка. Теперь внесем изменения в корневые правила грамматики, добавив процедуру интерпретации.

// подцепочка «Purpose» интерпретируется в поле «Purpose» факта «Payment»
// подцепочка «Address» интерпретируется в поле «Address» факта «Payment»
// подцепочка «Period» интерпретируется в поле «Period» факта «Payment»
Result -> Purpose interp(Payment.Purpose) AnyWord* Address interp(Payment.Address) AnyWord* Period interp(Payment.Period);
Result -> Purpose interp(Payment.Purpose) AnyWord* Address interp(Payment.Address);
Result -> Purpose interp(Payment.Purpose);

Конфигурационный файл

Далее создаем конфигурационный файл и сообщаем парсеру, где искать исходный текст, куда записывать результат, какие грамматики использовать и какие факты извлекать.

encoding "utf8"; // явно указываем кодировку

TTextMinerConfig {
	// указываем корневой словарь
	Dictionary = "dic.gzt";
	// входные данные
	Input = {File = "input.txt"}
	// указываем куда записывать результат работы парсера
	Output = {File = "output.txt"
			Format = text}
	// грамматики, которые будут использоваться при парсинге
	Articles = [
		{ Name = "payment" }
		]
	// факты, которые извлекаем
	Facts = [
		{ Name = "Payment" }
		]
	// показать отладочный вывод с результатами работы грамматики
	PrettyOutput = "pretty.html"
}

Запуск парсера

Запускается парсер из командной строки:

> tomitaparser.exe config.proto

В файл «input.txt» мы поместили исходный текст, размещенный в самом начале статьи. После работы парсер записал результат в файл «output.txt»:

Оплата за аренду торгового места № 2 по адресу ул Маршала Жукова , 15 за июнь 2020г . , в сумме 5400,00 р 
	Payment
	{
		Purpose = аренда торгового места
		Address = ул Маршала Жукова
		Period = июнь 2020г
	}
Перечисление денежных средств на Шустрик У. С. в субаренду автомобиля в сумме 15299,00 руб , без НДС 
	Payment
	{
		Purpose = субаренда автомобиля
	}
Оплата за найм общежития № 575 по адресу пр Обуховской обороны , 145 от 17.09.2020 за февраль 2020 года , с ндс 18% - 5300 рублей . 
	Payment
	{
		Purpose = найм общежития
		Address = пр Обуховской обороны
		Period = февраль 2020
	}
Оплата за аренду нежилого помещения по адресу Малая Садовая ул , 23 , договор 51 от 01.09.2020 - 7500 рублей . 
	Payment
	{
		Purpose = аренда нежилого помещения
		Address = Садовая ул
	}
Частичная оплата по Договору аренды № 1-03а от 01.07.2020 за аренду помещения по проспекту Дальневосточный 211 в октябре 2020 Сумма 23000 в т.ч.НДС ( 18% ) 
	Payment
	{
		Purpose = аренда помещения
		Address = проспект Дальневосточный
		Period = октябрь 2020
	}

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