Время прочтения: 6 мин.
«Контекстно-свободные грамматики» – громоздкое название. Хотя это безобидная и очень полезная штука. Феномен пришел в IT из лингвистики и натворил море чудес – с их помощью можно искать в тексте почти все нужные конструкции: адрес, денежную сумму, словосочетания с глаголом «раскидывать», кличку животного – что угодно. Чтобы раскрыть всю мощь КС-грамматик на примере, предлагаю представить, что перед нами поставили задачу извлечь названия организаций из текста.
Посмотрим на исходные примеры (данные выдуманы, все совпадения случайны):
texts = [
'Вознаграждение по Договору "00" июля 2019г. ООО ЛампыДок ИНН 0000011111. НДС…',
'Оплата задолженности ООО Чистки-СВ за содержание зданий за период с 09.03.2010г…',
'Оплата ИП Левин К.Д.: предоплата за июнь по сч. №0010011111 от 02.06.2017…',
'Оплата ИП Моргушина Линда Петровна: обслуживание б/к по сч. №00101111 от 01.11…'
]
Вариантов, как выполнить задачу, несколько:
а) Разбить на токены (в данном случае слова и предложные конструкции), вытянуть цепочку по ключевым словам (ООО, ИП и др.);
б) Использовать NER-модули Python-библиотек. В статье рассматривается Natasha;
в) Использовать парсер для поиска заданных цепочек с помощью контекстно-свободных грамматик;
Сравним варианты б и в.
Парсеры на основе КС-грамматик производят поиск фактов по предложениям, используя правила, по которым строятся цепочки.
Цепочка – это последовательность, любая языковая конструкция. Например, «г. Москва, ул. Рязанская, д. 21», «Евгения Лисовская работает переводчиком». В примерах подчеркнуты компоненты формулы (дескрипторы), которые можно задать правилом и производить поиск подобных сочетаний в любом тексте: Name + Surn + «работает» + Noun.
Факт – это структурированная информация – результат анализа цепочек, – обычно представленная статьей:
RFact(text=’Евгения Лисовская работает переводчиком ’ ,
span={name: ‘Евгения’,
surname: ‘Лисовская’,
profession: ‘переводчик}’
Рассмотрим процесс извлечения фактов из текста на примере библиотеки Natasha.
Необходимые импорты:
from yargy import Parser, rule, or_, and_
from yargy.interpretation import fact
from yargy.predicates import gram, eq, is_capitalized, length_eq
from yargy.pipelines import morph_pipeline
from slovnet import NER
from navec import Navec
navec = Navec.load('C:/…/ Ntsh/navec_news_v1_1B_250K_300d_100q.tar')
ner = NER.load('C:/… /Ntsh/slovnet_ner_news_v1.tar')
ner.navec(navec)
«navec» – эмбеддинги, их можно загрузить здесь.
«slovnet» – модели, скачивать на этой странице.
Первым делом определимся, из каких полей будет состоять факт. Для рассматриваемых текстов достаточно двух: форма организации и ее название. На Python факт реализуется следующим образом:
Orgname = fact(
'Org',
['orgform', 'name'])
Обозначим правило поиска организационных форм для взятых примеров. Так как их ограниченное количество, задаем набор в явном виде:
ORGFORM = morph_pipeline([
'ООО ',
'ИП '])
morph_pipeline – это газеттир[1] – т.н. корневой словарь, в котором собирается информация обо всех словарях, грамматиках и прочих элементах.
Вторая часть искомой сущности – название. Оно формируется обобщенными правилами:
NAME = or_(gram('Surn'),
gram('Name'),
gram('NOUN'))
С предикатом or_ все просто. Он сообщает парсеру: «Далее перечисляются правила, связанные отношением “или”». Наименование компании может быть выражено фамилией, именем или существительным. Так и запишем. У Natasha есть целый набор предикатов, они лежат в справочнике.
Все части наименования собраны. Если оставить так, как есть, парсер просто соберет цепочки, соответствующие правилам. Чтобы на выходе получить факты, нужно включить в код интерпретацию:
ORGANIZATION = rule(
ORGFORM.interpretation(Orgname.orgform),
NAME.interpretation(Orgname.name).interpretation(Orgname))
#Передаем парсеру объект «организация» и запускаем поиск:
orgparser = Parser(ORGANIZATION)
def orgs_extract(text, parser):
for match in parser.findall(text):
found_values = match.tokens
return found_values
orgs = [orgs_extract(txt, orgparser) for txt in texts]
Вывод для первого текста:
Дополнительная ценность – координаты сущности в тексте, которые могут пригодиться для расширенного анализа документа.
Если нужны только цепочки, изменим цикл в предыдущей функции:
for match in parser.findall(text):
found_values = [token.value for token in match.tokens]
Вывод:
def organizations_mrkup(text): organizations = [] markup = ner(text) spans = markup.spans for i in range(len(spans)): if spans[i].type == ‘ORG’: org_spans = spans[i] start, stop = org_spans.start, org_spans.stop organizations.append([text[start:stop], (start, stop)]) return organizations |
Мы разобрали основы того, как можно извлекать и структурировать любые факты цепочки из текста с помощью библиотеки Natasha. Но если говорить об организациях – это довольно распространенная сущность, запрос на поиск которой неисчерпаем. Поэтому разработчики библиотеки включили их поиск в NER-модуль. И задача с поиском названия сводится к одной функции:
def organizations_mrkup(text):
organizations = []
markup = ner(text)
spans = markup.spans
for i in range(len(spans)):
if spans[i].type == 'ORG':
org_spans = spans[i]
start, stop = org_spans.start, org_spans.stop
organizations.append([text[start:stop], (start, stop)])
return organizations
Вывод:
Из достоинств — название компании из второго текста вытянуто точно и полностью (в отличие от ручной настройки). Организации с упоминаниями форм «ООО», «ОАО» модуль видит лучше всего.
Сравним точность подходов по расстояниею Левенштейна:
NER | Parser | |
‘ООО ЛампыДок’ | 15 | 0 |
‘ООО Чистки-СВ’ | 0 | 3 |
‘ИП Левин К.Д.’ | 13 | 4 |
‘ИП Моргушина Линда Петровна’’ | 24 | 16 |
Итого | 52 | 23 |
Сравнение проходит на крайне небольшой выборке, при увеличении количества текстов показатели выровняются.
Поиск названий для ИП (с включением полных имен или инициалов) можно настроить в правилах для парсера.
Пример полной настройки:
PT = eq('.')
Name = fact(
'Name',
['surname', 'name', 'lastname', 'orgnm', ‘abbr’])
ORGFORM = morph_pipeline([
'ООО',
'ИП'])
SURN = gram('Surn').interpretation(
Name.surname)
NAME = gram('Name').interpretation(
Name.name)
ABBR = (is_capitalized()).interpretation(
Name.abbr)
PATR = (is_capitalized()).interpretation(Name.lastname)
INIT = and_(length_eq(1), is_capitalized())
FRST_INIT = INIT.interpretation(Name.name)
LST_INIT = INIT.interpretation(Name.lastname)
ORGNAME = and_(gram('NOUN'), is_capitalized()).interpretation(
Name.orgnm)
)
ORGANIZATION = or_(
rule(ORGFORM, SURN, FRST_INIT, PT, LST_INIT, PT),
rule(ORGFORM, FRST_INIT, PT, LST_INIT, PT, SURN),
rule(ORGFORM, ORGNAME, TR, ABBR)
rule(ORGFORM, SURN, FRST_INIT, PT),
rule(ORGFORM, FRST_INIT, PT, SURN),
rule(ORGFORM, NAME, SURN, PATR),
rule(ORGFORM, SURN, NAME, PATR),
rule(ORGFORM, SURN, FRST_INIT),
rule(ORGFORM, FRST_INIT, SURN),
rule(ORGFORM, NAME, SURN),
rule(ORGFORM, SURN, NAME),
rule(ORGFORM, ORGNAME),
).interpretation(Name)
ORG = Parser(ORGANIZATION)
Вывод:
Использование контекстно-свободных грамматик в NLP – огромное поле возможностей для работы с текстом. Для некоторых задач подобный подход – overkill, но для массового поиска фактов/цепочек в огромных данных парсер – отличный помощник, который обеспечит сравнимо высокую точность.
[1] Слово «газеттир» придумали в «Яндексе», это обозначение формата словарей — .gzt, с которыми работает Томита-парсер.