Pull to refresh

NLP: выделяем факты из текста с помощью Томита-парсера

Reading time9 min
Views10K

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

Tags:
Hubs:
Total votes 13: ↑13 and ↓0+13
Comments2

Articles