Pull to refresh

Yargy-парсер и библиотека Natasha. Извлечения структурированной информации из текстов на русском языке

Reading time 12 min
Views 82K
В 2020 году библиотека Natasha значительно обновилась, на Хабре опубликована статья про актуальную версию. Чтобы использовать инструменты, описанные в этом тексте, установите старую версию библиотеки pip install natasha<1 yargy<0.13.

Раздел про Yargy-парсер актуален и сейчас.


Есть стандартная задача извлечения именованных сущностей из текста (NER). На входе текст, на выходе структурированные, нормализованные объекты, например, с именами, адресами, датами:



Задача старая и хорошо изученная, для английского языка существует масса коммерческих и открытых решений: Spacy, Stanford NER, OpenNLP, NLTK, MITIE, Google Natural Language API, ParallelDots, Aylien, Rosette, TextRazor. Для русского тоже есть хорошие решения, но они в основном закрытые: DaData, Pullenti, Abbyy Infoextractor, Dictum, Eureka, Promt, RCO, AOT, Ahunter. Из открытого мне известен только Томита-парсер и свежий Deepmipt NER.

Я занимаюсь анализом данных, задача обработки текстов одна из самых частых. На практике оказывается, что, например, извлечь имена из русского текста совсем непросто. Есть готовое решение в Томита-парсере, но там неудобная интеграция с Python. Недавно появилось решение от ребят из iPavlov, но там имена не приводятся к нормальной форме. Для извлечения, например, адресов («ул. 8 Марта, д.4», «Ленинский проезд, 15») открытых решений мне не известно, есть pypostal, но он чтобы парсить адреса, а не искать их в тексте. C нестандартными задачами типа извлечения ссылок на нормативные акты («ст. 11 ГК РФ», «п. 1 ст. 6 Закона № 122-ФЗ») вообще непонятно, что делать.

Год назад Дима Веселов начал проект Natasha. С тех пор код был значительно доработан. Natasha была использована в нескольких крупных проектах. Сейчас мы готовы рассказать о ней пользователям Хабра.
Natasha — это аналог Томита-парсера для Python (Yargy-парсер) плюс набор готовых правил для извлечения имён, адресов, дат, сумм денег и других сущностей.
В статье показано, как использовать готовые правила из Natasha и, самое главное, как добавлять свои с помощью Yargy-парсера.


Готовые правила


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

Имена


Воспользоваться готовыми правилами просто:

from natasha import NamesExtractor
from natasha.markup import show_markup, show_json

extractor = NamesExtractor()

text = '''
Благодарственное письмо   Хочу поблагодарить учителей моего, теперь уже бывшего, одиннадцатиклассника:  Бушуева Вячеслава Владимировича и Бушуеву Веру Константиновну. Они вовлекали сына в интересные внеурочные занятия, связанные с театром и походами.

Благодарю прекрасного учителя 1"А" класса - Волкову Наталью Николаевну, нашего наставника, тьютора - Ларису Ивановну, за огромнейший труд, чуткое отношение к детям, взаимопонимание! Огромное спасибо!
'''
matches = extractor(text)
spans = [_.span for _ in matches]
facts = [_.fact.as_json for _ in matches]
show_markup(text, spans)
show_json(facts)

>>> Благодарственное письмо   Хочу поблагодарить учителей моего, теперь уже бывшего, одиннадцатиклассника:  [[Бушуева Вячеслава Владимировича]] и [[Бушуеву Веру Константиновну]]. Они вовлекали сына в интересные внеурочные занятия, связанные с театром и походами.

Благодарю прекрасного учителя 1"А" класса - [[Волкову Наталью Николаевну]], нашего наставника, тьютора - [[Ларису Ивановну]], за огромнейший труд, чуткое отношение к детям, взаимопонимание! Огромное спасибо!

[
  {
    "first": "вячеслав",
    "middle": "владимирович",
    "last": "бушуев"
  },
  {
    "first": "вера",
    "middle": "константиновна",
    "last": "бушуева"
  },
  {
    "first": "наталья",
    "middle": "николаевна",
    "last": "волкова"
  },
  {
    "first": "лариса",
    "middle": "ивановна"
  }
]

В 2016 году проходило соревнование factRuEval-2016 по извлечению именованных сущностей. Среди участников были крупные компании: ABBYY, RCO. У топовых решений F1-мера для имён была 0.9+. У Natasha результат хуже — 0.78. Проблема в основном c иностранными именами и сложными фамилиями, например: «Харуки Мураками», "… главой Афганистана Хамидом Карзаем", «Остап Бендер встречается с Кисой Воробьяниновым ...». Для текстов с русскими именами качество получается ~0.95. Можно, например, извлекать имена учителей с сайтов школ, агрегировать отзывы:


Адреса


Интерфейс такой же как для имён, только NamesExtractor меняется на AddressExtractor:

from natasha import AddressExtractor
from natasha.markup import show_markup, show_json

extractor = AddressExtractor()

text = '''
Предлагаю вернуть прежние границы природного парка №71 на Инженерной улице 2.

По адресу Алтуфьевское шоссе д.51 (основной вид разрешенного использования: производственная деятельность, склады) размещен МПЗ. Жители требуют незамедлительной остановки МПЗ и его вывода из района

Контакты О нас телефон 7 881 574-10-02 Адрес Республика Карелия,г.Петрозаводск,ул.Маршала Мерецкова, д.8 Б,офис 4
'''
matches = extractor(text)
spans = [_.span for _ in matches]
facts = [_.fact.as_json for _ in matches]
show_markup(text, spans)
show_json(facts)

>>> Предлагаю вернуть прежние границы природного парка №71 на [[Инженерной улице 2]].

По адресу [[Алтуфьевское шоссе д.51]] (основной вид разрешенного использования: производственная деятельность, склады) размещен МПЗ. Жители требуют незамедлительной остановки МПЗ и его вывода из района

Контакты О нас телефон 7 881 574-10-02 Адрес [[Республика Карелия,г.Петрозаводск,ул.Маршала Мерецкова, д.8 Б,офис 4]]

[
  {
    "parts": [
      {
        "name": "Инженерной",
        "type": "улица"
      },
      {
        "number": "2"
      }
    ]
  },
  {
    "parts": [
      {
        "name": "Алтуфьевское",
        "type": "шоссе"
      },
      {
        "number": "51",
        "type": "дом"
      }
    ]
  },
  {
    "parts": [
      {
        "name": "Карелия",
        "type": "республика"
      },
      {
        "name": "Петрозаводск",
        "type": "город"
      },
      {
        "name": "Маршала Мерецкова",
        "type": "улица"
      },
      {
        "number": "8 Б",
        "type": "дом"
      },
      {
        "number": "4",
        "type": "офис"
      }
    ]
  }
]

В factRuEval-2016 участникам предлагалось извлекать имена, названия организаций и географические объекты. Независимых тестовых данных для оценки качества работы с адресами, на сколько я знаю, не существует. За несколько лет работы у нас накопились сотни тысяч строк вида «Адрес: 443041 г. Самара ул. Ленинская, д.168», «Адрес г. Иркутск, ул. Байкальская, д. 133, офис 1 (вход со двора).». Для оценки качества сделана случайная выборка из 1000 адресов, результаты проверены вручную, ~90% строк обработались корректно. Проблемы возникают, в основном, с названиями улиц, например: «г. Волжск, 2-ая промышленная, стр. 2», «111674, г. Москва, Дмитриевского, д. 17».

В 2017 году параллельно с историей про реконструкцию в Москве обсуждались новые Правила землепользования и застройки (ПЗЗ). Был проведён опрос населения. Более 100 000 комментариев были выложены в открытый доступ в виде огромного pdf-файла. Никита Кузнецов с помощью Natasha извлёк упомянутые адреса и посмотрел в каких районах закон поддерживают, а в каких нет:


Визуально оценить качество работы AddressExtractor на датасете с комментариями к ПЗЗ можно в репозитории с примерами.

Другие правила


Ещё в Natasha есть правила для дат и денег. Интерфейс такой же как у AddressExtractor и NamesExtractor.

from natasha import (
    NamesExtractor,
    AddressExtractor,
    DatesExtractor,
    MoneyExtractor
)
from natasha.markup import show_markup, show_json

extractors = [
    NamesExtractor(),
    AddressExtractor(),
    DatesExtractor(),
    MoneyExtractor()
]


text = '''
Взыскать к индивидуального предпринимателя Иванова Костантипа Петровича дата рождения 10 января 1970 года, проживающего по адресу город Санкт-Петербург, ул. Крузенштерна, дом 5/1А 8 000 (восемь тысяч) рублей 00 копеей госпошлины в пользу бюджета РФ 
'''
spans = []
facts = []
for extractor in extractors:
    matches = extractor(text)
    spans.extend(_.span for _ in matches)
    facts.extend(_.fact.as_json for _ in matches)
show_markup(text, spans)
show_json(facts)

>>> Взыскать к индивидуального предпренимателя [[Иванова Костантипа Петровича]] дата рождения [[10 января 1970 года]], проживающего по адресу [[город Санкт-Петербург, ул. Крузенштерна, дом 5/1А]][[Крузенштерна]], дом 5/1А [[8 000 (восемь тысяч) рублей 00]] копеей госпошлины в пользу бюджета РФ 

[
  {
    "first": "костантип",
    "middle": "петрович",
    "last": "иванов"
  },
  {
    "last": "крузенштерн"
  },
  {
    "parts": [
      {
        "name": "Санкт-Петербург",
        "type": "город"
      },
      {
        "name": "Крузенштерна",
        "type": "улица"
      },
      {
        "number": "5/1А",
        "type": "дом"
      }
    ]
  },
  {
    "year": 1970,
    "month": 1,
    "day": 10
  },
  {
    "integer": 8000,
    "currency": "RUB",
    "coins": 0
  }
]

Интерфейс Natasha очень простой: e = Extractor(); r = e(text); .... Пользователю недоступны никакие настройки. На практике обойтись только готовыми правилами получается редко. Например, Natasha не разберёт дату "«21» апреля 2017", потому что в правилах не предусмотрен номер дня в кавычках. Библиотека не разберёт адрес «Люберецкий район, деревня Мотяково, д. 61/2», потому что в нём нет названия улицы.

Часто приходится опускаться на уровень ниже, дополнять готовые правила и писать свои. Для этого использует Yargy-парсер. Все правила в Natasha написаны на нём. Yargy — сложная и интересная библиотека, в этой статье мы рассмотрим только простые примеры использования.

Yargy-парсер


Yargy-парсер — аналог яндексового Томита-парсера для Python. Правила для извлечения сущностей описываются с помощью контекстно-свободных грамматик и словарей.

Грамматики


Грамматики в Yargy записываются на специальном DSL-е. Так, например, будет выглядеть простое правило для извлечения дат в ISO-формате («2018-02-23», «2015-12-31»):

from yargy import rule, and_, Parser
from yargy.predicates import gte, lte


DAY = and_(
    gte(1),
    lte(31)
)
MONTH = and_(
    gte(1),
    lte(12)
)
YEAR = and_(
    gte(1),
    lte(2018)
)
DATE = rule(
    YEAR,
    '-',
    MONTH,
    '-',
    DAY
)

parser = Parser(DATE)
text = '''
2018-02-23,
2015-12-31;
8 916 364-12-01'''
for match in parser.findall(text):
    print(match.span, [_.value for _ in match.tokens])

>>> [1, 11) ['2018', '-', '02', '-', '23']
>>> [13, 23) ['2015', '-', '12', '-', '31']
>>> [33, 42) ['364', '-', '12', '-', '01']

Пока не очень впечатляет, похожую функциональность можно получить регуляркой r'\d\d\d\d-\d\d-\d\d', правда она будет метчить ерунду типа «1234-56-78».

Предикаты


gte и lte в примере выше — предикаты. В парсер встроено много готовых предикатов, есть возможность добавить свои. Для определения морфологии слов используется pymorphy2. Например, предикат, gram('NOUN') срабатывает на существительных, normalized('январь') метчит все формы слова «январь». Добавим правила для дат вида «8 января 2014», «15 июня 2001 г.»:

from yargy import or_
from yargy.predicates import caseless, normalized, dictionary


MONTHS = {
    'январь',
    'февраль',
    'март',
    'апрель',
    'мая',
    'июнь',
    'июль',
    'август',
    'сентябрь',
    'октябрь',
    'ноябрь',
    'декабрь'
}
MONTH_NAME = dictionary(MONTHS)
YEAR_WORDS = or_(
    rule(caseless('г'), '.'),
    rule(normalized('год'))
)
DATE = or_(
    rule(
        YEAR,
        '-',
        MONTH,
        '-',
        DAY
    ),
    rule(
        DAY,
        MONTH_NAME,
        YEAR,
        YEAR_WORDS.optional()
    )
)

parser = Parser(DATE)
text = '''
8 января 2014 года, 15 июня 2001 г.,
31 февраля 2018'''
for match in parser.findall(text):
    print(match.span, [_.value for _ in match.tokens])

>>> [21, 36) ['15', 'июня', '2001', 'г', '.']
>>> [1, 19) ['8', 'января', '2014', 'года']
>>> [38, 53) ['31', 'февраля', '2018']

Интерпретация


Найти подстроку с фактом обычно недостаточно. Например, для текста «8 мая по поручению президента Владимира Путина» парсер должен вернуть не просто «8 мая», «Владимира Путина», а Date(month=5, day=8), Name(first='Владимир', last='Путин'), для этого в Yargy предусмотрена процедура интерпретации. Результат работы парсера — это дерево разбора:

match = parser.match('05 февраля 2011 года')
match.tree.as_dot


(R0, R1 — технические вершины, «R» сокращение от «Rule»)

Для интерпретации пользователь «развешивает» на узлы дерева пометки с помощью метода .interpretation(...):

from yargy.interpretation import fact


Date = fact(
    'Date',
    ['year', 'month', 'day']
)


DAY = and_(
    gte(1),
    lte(31)
).interpretation(
    Date.day
)
MONTH = and_(
    gte(1),
    lte(12)
).interpretation(
    Date.month
)
YEAR = and_(
    gte(1),
    lte(2018)
).interpretation(
    Date.year
)
MONTH_NAME = dictionary(
    MONTHS
).interpretation(
    Date.month
)
DATE = or_(
    rule(YEAR, '-', MONTH, '-', DAY),
    rule(
        DAY,
        MONTH_NAME,
        YEAR,
        YEAR_WORDS.optional()
    )
).interpretation(Date)

match = parser.match('05 февраля 2011 года')
match.tree.as_dot



parser = Parser(DATE)
text = '''8 января 2014 года, 2018-12-01'''
for match in parser.findall(text):
    print(match.fact)

>>> Date(year='2018', month='12', day='01')
>>> Date(year='2014', month='января', day='8')

Нормализация


В примере с датами нужно привести названия месяцев, дни и годы к числам, для этого в Yargy встроена процедура нормализации. Внутри .interpretation(...) пользователь указывает, как нормировать поля:

from datetime import date


Date = fact(
    'Date',
    ['year', 'month', 'day']
)
class Date(Date):
    @property
    def as_datetime(self):
        return date(self.year, self.month, self.day)


MONTHS = {
    'январь': 1,
    'февраль': 2,
    'март': 3,
    'апрель': 4,
    'мая': 5,
    'июнь': 6,
    'июль': 7,
    'август': 8,
    'сентябрь': 9,
    'октябрь': 10,
    'ноябрь': 11,
    'декабрь': 12
}


DAY = and_(
    gte(1),
    lte(31)
).interpretation(
    Date.day.custom(int)
)
MONTH = and_(
    gte(1),
    lte(12)
).interpretation(
    Date.month.custom(int)
)
YEAR = and_(
    gte(1),
    lte(2018)
).interpretation(
    Date.year.custom(int)
)
MONTH_NAME = dictionary(
    MONTHS
).interpretation(
    Date.month.normalized().custom(MONTHS.__getitem__)
)
DATE = or_(
    rule(YEAR, '-', MONTH, '-', DAY),
    rule(
        DAY,
        MONTH_NAME,
        YEAR,
        YEAR_WORDS.optional()
    )
).interpretation(Date)

parser = Parser(DATE)
text = '''8 января 2014 года, 2018-12-01'''
for match in parser.findall(text):
    record = match.fact
    print(record, repr(record.as_datetime))

>>> Date(year=2018, month=12, day=1) datetime.date(2018, 12, 1)
>>> Date(year=2014, month=1, day=8) datetime.date(2014, 1, 8)

match = parser.match('31 февраля 2014 г.')
match.fact.as_datetime

>>> ValueError: day is out of range for month

Ура, мы повторили маленький кусочек функциональности библиотеки dateparser. Если нужно извлечь из текста, например, только даты, то стоит выбрать готовую специализированную библиотеку. Решение будет работать быстрее, качество будет выше. Yargy нужен для объёмных, нестандартных задач.

Согласование


Рассмотрим простое правило для извлечения имён. В словаре Opencorpora, который использует pymorphy2, для имён ставится метка Name, для фамилий — метка Surn. Будем считать именем пару слов Name Surn или Surn Name:

from yargy.predicates import gram


Name = fact(
    'Name',
    ['first', 'last']
)


FIRST = gram('Name').interpretation(
    Name.first.inflected()
)

LAST = gram('Surn').interpretation(
    Name.last.inflected()
)

NAME = or_(
    rule(
        FIRST,
        LAST
    ),
    rule(
        LAST,
        FIRST
    )
).interpretation(
    Name
)

У такого решения есть две проблемы:

1. Правило метчит имя и фамилию в разных падежах («Иванова Лёша», «Петрову Ромой»)
2. Женские фамилии после нормализации становятся мужскими

parser = Parser(NAME)
text = '''
... с улицы Грибоедова Антон свернул на ...
... Бушуева Вячеслава и Бушуеву Веру ...
'''

for match in parser.findall(text):
    print(match.fact)

>>> Name(first='антон', last='грибоедов')
>>> Name(first='вячеслав', last='бушуев')
>>> Name(first='вера', last='бушуев')

Чтобы решить эти проблемы в Yargy есть механизм согласования. С помощью метода .match(...) пользователь указывает ограничения на правила:

from yargy.relations import gnc_relation


gnc = gnc_relation()  # согласование по gender, number и case (падежу, числу и роду)

Name = fact(
    'Name',
    ['first', 'last']
)


FIRST = gram('Name').interpretation(
    Name.first.inflected()
).match(gnc)

LAST = gram('Surn').interpretation(
    Name.last.inflected()
).match(gnc)

NAME = or_(
    rule(
        FIRST,
        LAST
    ),
    rule(
        LAST,
        FIRST
    )
).interpretation(
    Name
)


parser = Parser(NAME)
text = '''
... с улицы Грибоедова Антон свернул на ...
... Бушуева Вячеслава и Бушуеву Веру ...
'''

for match in parser.findall(text):
    print(match.fact)

>>> Name(first='вячеслав', last='бушуев')
>>> Name(first='вера', last='бушуева')

Достоинства и недостатки


Natasha предоставляет под лицензией MIT решения, которых раньше не было в открытом доступе (или я о них не знаю). Например, раньше нельзя было просто взять и извлечь структурированные имена и адреса из русскоязычного текста, а теперь можно. Раньше для Python не было чего-то вроде Томита-парсера, теперь есть.

Постараюсь коротко сформулировать недостатки:
  1. Вручную составленные правила.
    Natasha разбирает только те словосочетания, для которых заранее были составлены правила. Может показаться, что нереально написать правила, например, для имён в произвольном тексте, слишком они разные. На практике всё не так плохо:
    1. Если посидеть недельку, то всё-таки для 80% имён составить правила можно.
    2. Обычно нужно работать не с произвольными текстами, а с текстами на контролируемом естественном языке: резюме, решения судов, нормативные акты, раздел сайта с контактами.
    3. В правилах для Yargy-парсера можно использовать разметку, полученную методами машинного обучения.

  2. Медленная скорость работы.
    Начнём с того, что Yargy реализует алгоритм Earley parser, его сложность O(n3), где n — число токенов. Код написан на чистом Python, с упором на читаемость, а не оптимизацию. Короче говоря, библиотека работает медленно. Например, на задаче извлечения имён Natasha в 10 раз медленнее Томита-парсера. На практике с этим можно жить:
    1. Хорошо помогает PyPy. Случается ускорение в 10 раз, в среднем в ~3-4 раза.
    2. Выполнение в несколько потоков, на нескольких машинах. Задача хорошо параллелится, машины в аренду сейчас легко доступны.

  3. Ошибки в стандартных правилах.
    Например, качество извлечения имён у Natasha очень далеко от SOTA. На практике из коробки библиотека не всегда показывает хорошее качество, нужно дорабатывать правила под себя.

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


Ссылки


Адрес проекта на Гитхабе простой — github.com/natasha.

Установка — pip install natasha. Библиотека тестируется на Python 2.7, 3.3, 3.4, 3.5, 3.6, PyPy и РyPy3.

Документация для пакета стандартных правил короткая, интерфейс очень простой — nbviewer.jupyter.org/github/natasha/natasha/blob/master/docs.ipynb. Документация для Yargy более объёмная и сложная — nbviewer.jupyter.org/github/natasha/yargy/blob/master/docs/index.ipynb. Yargy интересный и непростой инструмент, возможно, существующей документации будет недостаточно. Есть желание опубликовать на Хабре серию уроков по Yargy. Вы можете написать в комментариях, какие темы стоит осветить, например:
  1. Скорость выполнения, обработка больших объёмов текстов;
  2. Ручные правила и машинное обучение, гибридные решения;
  3. Примеры применения библиотеки в разных областях: разбор резюме, парсинг названий товаров, чат-боты.


Чат пользователей Natasha — t.me/natural_language_processing. Там можно попробовать задать вопросы по библиотеке.

Стенд для демонстрации стандартных правил — natasha.github.io. Можно ввести свой текст, посмотреть, как на нём отрабатывают стандартные правила:
Tags:
Hubs:
+85
Comments 33
Comments Comments 33

Articles