NLP - natural language processing
Большая часть данных в мире не структурирована – это просто тексты на русском или на любом другом языке. Извлеченные факты из таких текстов могут представлять особый интерес для бизнеса, поэтому подобные задачи возникают сплошь и рядом. Этим вопросом занимается отдельное направление искусственного интеллекта: обработка естественного языка, тот самый 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 мире по сей день. Теперь в наших руках появился еще один доступный инструмент. Как видите, создать свою первую грамматику можно довольно легко, потратив при этом немного времени на изучение, т.к. по Томите приведена подробная и исчерпывающая документация. Тем не менее, качество выделенных фактов сильно зависит от самого разработчика и его знаний в экспертной области.