Yargy

Yargy — библиотека для извлечения структурированной информации из текстов на русском языке. Правила описываются контекстно-свободными грамматиками и словарями ключевых слов. Банк готовых правил для имён, дат, адресов и других сущностей доступен в репозитории Natasha.

Парсер реализует алгоритм Earley parser. Библиотека написана на чистом Python, поддерживает Python 3.5+ и Pypy 3, использует Pymorphy2 для работы с морфологией.

Томита-парсер

Томита-парсер — популярный инструмент для извлечения структурированный информации из текстов на русском языке. Грубо говоря, Yargy — версия Томита-парсера для Python, написанная с нуля:

Томита-парсер Yargy
Разрабатывался много лет внутри Яндекса Open source, разрабатывается сообществом
10 000+ строк кода на C++ 1000+ на Python
CLI Python-библиотека
Protobuf + конфигурационные файлы Python DSL
Нет готовых правил Natasha — готовые правила для извлечения имён, дат, адресов и других сущностей
Медленный Очень медленный

Примеры

Для работы с русским языком в Yargy встроен морфологический анализатор Pymorphy2. Найдём в тексте топонимы, которые начинаются прилагательным и заканчиваются словами "федерация" или "республика". Например, "Российская Федерация", "Донецкая народная республика":

In [1]:
from yargy import Parser, rule, and_
from yargy.predicates import gram, is_capitalized, dictionary


GEO = rule(
    and_(
        gram('ADJF'),  # так помечается прилагательное, остальные пометки описаны в
                       # http://pymorphy2.readthedocs.io/en/latest/user/grammemes.html
        is_capitalized()
    ),
    gram('ADJF').optional().repeatable(),
    dictionary({
        'федерация',
        'республика'
    })
)


parser = Parser(GEO)
text = '''
В Чеченской республике на день рождения ...
Донецкая народная республика провозгласила ...
Башня Федерация — одна из самых высоких ...
'''
for match in parser.findall(text):
    print([_.value for _ in match.tokens])
['Чеченской', 'республике']
['Донецкая', 'народная', 'республика']

Настоящие сложные грамматики для топонимов собраны в репозитории Natasha.

Найти подстроку в тексте не достаточно, нужно разбить её на поля и нормализовать. Например, из фразы "12 марта по приказу президента Владимира Путина ...", извлечём объект Person(position='президент', Name(first='Владимир', last='Путин')).

In [2]:
from yargy import Parser
from yargy.predicates import gram
from yargy.pipelines import morph_pipeline
from yargy.interpretation import fact
from IPython.display import display

Person = fact(
    'Person',
    ['position', 'name']
)
Name = fact(
    'Name',
    ['first', 'last']
)


POSITION = morph_pipeline([
    'премьер министр',
    'президент'
])

NAME = rule(
    gram('Name').interpretation(
        Name.first.inflected()
    ),
    gram('Surn').interpretation(
        Name.last.inflected()
    )
).interpretation(
    Name
)

PERSON = rule(
    POSITION.interpretation(
        Person.position.inflected()
    ),
    NAME.interpretation(
        Person.name
    )
).interpretation(
    Person
)


parser = Parser(PERSON)
text = '''
12 марта по приказу президента Владимира Путина ...
'''
for match in parser.findall(text):
    display(match.fact)
Person(
    position='президент',
    name=Name(
        first='владимир',
        last='путин'
    )
)

Грамматики для имён собраны в репозитории Natasha

Токенизатор

Парсер работает с последовательностью токенов. Встроенный в Yargy токенизатор простой и предсказуемый:

In [3]:
from yargy.tokenizer import MorphTokenizer


tokenizer = MorphTokenizer()
text = '''Ростов-на-Дону
Длительностью 18ч. 10мин.
Яндекс.Такси
π ≈ 3.1415
1 500 000$
http://vk.com
'''
for line in text.splitlines():
    print([_.value for _ in tokenizer(line)])
['Ростов', '-', 'на', '-', 'Дону']
['Длительностью', '18', 'ч', '.', '10', 'мин', '.']
['Яндекс', '.', 'Такси']
['π', '≈', '3', '.', '1415']
['1', '500', '000', '$']
['http', ':', '/', '/', 'vk', '.', 'com']

Для каждого токена Pymorph2 возвращает набор граммем. Например, "NOUN, sing, femn" — "существительное в единственном числе женского рода". Полный список в документации Pymorph2.

Вне контекста слово имеет несколько вариантов разбора. Например, "стали" — глагол (VERB) во фразе "мы стали лучше" и существительное (NOUN) в "марки стали":

In [4]:
tokenizer = MorphTokenizer()
list(tokenizer('марки стали'))
Out[4]:
[MorphToken(
     value='марки',
     span=[0, 5),
     type='RU',
     forms=[Form('марка', Grams(NOUN,femn,gent,inan,sing)),
      Form('марка', Grams(NOUN,femn,inan,nomn,plur)),
      Form('марка', Grams(NOUN,accs,femn,inan,plur)),
      Form('маркий', Grams(ADJS,Qual,plur)),
      Form('марк', Grams(NOUN,Name,anim,masc,nomn,plur))]
 ),
 MorphToken(
     value='стали',
     span=[6, 11),
     type='RU',
     forms=[Form('стать', Grams(VERB,indc,intr,past,perf,plur)),
      Form('сталь', Grams(NOUN,femn,gent,inan,sing)),
      Form('сталь', Grams(NOUN,datv,femn,inan,sing)),
      Form('сталь', Grams(NOUN,femn,inan,loct,sing)),
      Form('сталь', Grams(NOUN,femn,inan,nomn,plur)),
      Form('сталь', Grams(NOUN,accs,femn,inan,plur))]
 )]

Токенизатор работает на правилах. В справочнике показано, как менять стандартные правила и добавлять новые.

Предикаты

Предикат принимает токен, возвращает True или False. В Yargy встроен набор готовых предикатов. Операторы and_, or_ и not_ комбинируют предикаты:

In [5]:
from yargy import and_, not_
from yargy.tokenizer import MorphTokenizer
from yargy.predicates import is_capitalized, eq


tokenizer = MorphTokenizer()
token = next(tokenizer('Стали'))

predicate = is_capitalized()
assert predicate(token) == True

predicate = and_(
    is_capitalized(),
    not_(eq('марки'))
)
assert predicate(token) == True

custom создаёт предикат из произвольной функции. Например, предикат для римских цифр:

In [6]:
from pymorphy2.shapes import is_roman_number

from yargy.parser import Context
from yargy.tokenizer import Tokenizer
from yargy.predicates import custom


tokenizer = Tokenizer()
token = next(tokenizer('XL'))

predicate = custom(is_roman_number, types='LATIN')
predicate = predicate.activate(Context(tokenizer))  # проверяется, что tokenizer поддерживает тип 'LATIN'
assert predicate(token) == True

token = next(tokenizer('XS'))
assert predicate(token) == False

Газеттир

Газеттир работает с последовательностью слов. Например, вместо:

In [7]:
from yargy import or_, rule
from yargy.predicates import normalized

RULE = or_(
    rule(normalized('dvd'), '-', normalized('диск')),
    rule(normalized('видео'), normalized('файл'))
)

удобно использовать morph_pipeline:

In [8]:
from yargy import Parser
from yargy.pipelines import morph_pipeline


RULE = morph_pipeline([
    'dvd-диск',
    'видео файл',
    'видеофильм',
    'газета',
    'электронный дневник',
    'эссе',
])

parser = Parser(RULE)
text = 'Видео файл на dvd-диске'
for match in parser.findall(text):
    print([_.value for _ in match.tokens])
['Видео', 'файл']
['dvd', '-', 'диске']

Список газеттиров в справочнике.

Грамматики

В Yargy контекстно-свободная грамматика описывается конструкциями Python. Например, традиционная запись грамматики размеров одежды:

KEY -> р. | размер
VALUE -> S | M | L
SIZE -> KEY VALUE

Так она выглядит в Yargy:

In [9]:
from yargy import rule, or_


KEY = or_(
    rule('р', '.'),
    rule('размер')
).named('KEY')
VALUE = or_(
    rule('S'),
    rule('M'),
    rule('L'),
).named('VALUE')
SIZE = rule(
    KEY,
    VALUE
).named('SIZE')
SIZE.normalized.as_bnf
Out[9]:
SIZE -> KEY VALUE
KEY -> 'р' '.' | 'размер'
VALUE -> 'S' | 'M' | 'L'

В Yargy терминал грамматики — предикат. Используем встроенный предикат in_, сократим запись VALUE:

In [10]:
from yargy.predicates import in_


VALUE = rule(
    in_('SML')
).named('VALUE')
SIZE = rule(
    KEY,
    VALUE
).named('SIZE')
SIZE.normalized.as_bnf
Out[10]:
SIZE -> KEY VALUE
KEY -> 'р' '.' | 'размер'
VALUE -> in_(...)

Как быть, когда правая часть правила ссылается на левую? Например:

EXPR -> a | ( EXPR + EXPR )

В Python нельзя использовать необъявленные переменные. Для рекурсивных правил, есть конструкция forward:

In [11]:
from yargy import forward


EXPR = forward()
EXPR.define(or_(
    rule('a'),
    rule('(', EXPR, '+', EXPR, ')')
).named('EXPR'))
EXPR.normalized.as_bnf
Out[11]:
EXPR -> 'a' | '(' EXPR '+' EXPR ')'

Рекурсивные правила описывают последовательности токенов произвольной длины. Грамматика для текста в кавычках:

In [12]:
from yargy import not_
from yargy.predicates import eq


WORD = not_(eq('»'))
TEXT = forward()
TEXT.define(or_(
    rule(WORD),
    rule(WORD, TEXT)
))
TITLE = rule(
    '«',
    TEXT,
    '»'
).named('TITLE')
TITLE.normalized.as_bnf
Out[12]:
TITLE -> '«' R0 '»'
R0 -> not_('»') | not_('»') R0

Для удобства в Yargy есть метод repeatable с ним запись короче. Библиотека автоматически добавит forward:

In [13]:
TITLE = rule(
    '«',
    not_(eq('»')).repeatable(),
    '»'
).named('TITLE')
TITLE.normalized.as_bnf
Out[13]:
TITLE -> '«' R0 '»'
R0 -> not_('»') R0 | not_('»')

Парсер

У парсера есть два метода: findall и match. findall находит все непересекающиеся подстроки, которые удовлетворяют грамматике:

In [14]:
parser = Parser(
    or_(
        PERSON,
        TITLE
    )
)
text = 'Президент Владимир Путин в фильме «Интервью с Путиным» ..'
for match in parser.findall(text):
    print([_.value for _ in match.tokens])
['Президент', 'Владимир', 'Путин']
['«', 'Интервью', 'с', 'Путиным', '»']

match — пытается разобрать весь текст целиком:

In [15]:
match = parser.match('Президент Владимир Путин')
print([_.value for _ in match.tokens])

match = parser.match('Президент Владимир Путин 25 мая')
print(match)
['Президент', 'Владимир', 'Путин']
None

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

Результат работы парсера — это дерево разбора. Грамматика и деревья разбора для дат:

In [16]:
from IPython.display import display

from yargy.predicates import (
    lte,
    gte,
    dictionary
)


MONTHS = {
    'январь',
    'февраль',
    'март',
    'апрель',
    'мая',
    'июнь',
    'июль',
    'август',
    'сентябрь',
    'октябрь',
    'ноябрь',
    'декабрь'
}


MONTH_NAME = dictionary(MONTHS)
MONTH = and_(
    gte(1),
    lte(12)
)
DAY = and_(
    gte(1),
    lte(31)
)
YEAR = and_(
    gte(1900),
    lte(2100)
)
DATE = or_(
    rule(DAY, MONTH_NAME, YEAR),
    rule(YEAR, '-', MONTH, '-', DAY),
    rule(YEAR, 'г', '.')
).named('DATE')
parser = Parser(DATE)
text = '''2015г.
18 июля 2016
2016-01-02
'''
for line in text.splitlines():
    match = parser.match(line)
    display(match.tree.as_dot)
G 0 DATE 1 2015 0->1 2 г 0->2 3 . 0->3
G 0 DATE 1 18 0->1 2 июля 0->2 3 2016 0->3
G 0 DATE 1 2016 0->1 2 - 0->2 3 01 0->3 4 - 0->4 5 02 0->5

Интерпретация — процесс преобразования дерева разбора в объект с набором полей. Для даты, например, нужно получить структуры вида Date(year=2016, month=1, day=2). Пользователь размечает дерево на вершины-атрибуты и вершины-конструкторы методом interpretation:

In [17]:
from yargy.interpretation import fact


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


DATE = or_(
    rule(
        DAY.interpretation(
            Date.day
        ),
        MONTH_NAME.interpretation(
            Date.month
        ),
        YEAR.interpretation(
            Date.year
        )
    ),
    rule(
        YEAR.interpretation(
            Date.year
        ),
        '-',
        MONTH.interpretation(
            Date.month
        ),
        '-',
        DAY.interpretation(
            Date.day
        )
    ),
    rule(
        YEAR.interpretation(
            Date.year
        ),
        'г', '.'
    )
).interpretation(
    Date
).named('DATE')

parser = Parser(DATE)
for line in text.splitlines():
    match = parser.match(line)
    display(match.tree.as_dot)
G 0 Date 1 Date.year 0->1 3 г 0->3 4 . 0->4 2 2015 1->2
G 0 Date 1 Date.day 0->1 3 Date.month 0->3 5 Date.year 0->5 2 18 1->2 4 июля 3->4 6 2016 5->6
G 0 Date 1 Date.year 0->1 3 - 0->3 4 Date.month 0->4 6 - 0->6 7 Date.day 0->7 2 2016 1->2 5 01 4->5 8 02 7->8

Из размеченного дерева библиотека собирает объект:

In [18]:
for line in text.splitlines():
    match = parser.match(line)
    display(match.fact)
Date(
    year='2015',
    month=None,
    day=None
)
Date(
    year='2016',
    month='июля',
    day='18'
)
Date(
    year='2016',
    month='01',
    day='02'
)

Подробнее об интерпретации в справочнике.

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

Содержание полей фактов нужно нормировать. Например, не Date('июня', '2018'), а Date(6, 2018); не Person('президента', Name('Владимира', 'Путина')), а Person('президент', Name('Владимир', 'Путин')). В Yargy пользователь при разметке дерева разбора указывает, как нормировать вершины-атрибуты. В примере слово "июня" будет приведено к нормальной форме "июнь" и заменится на число "6" с помощью словаря MONTHS. Год и день просто приводятся к int:

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


DATE = rule(
    DAY.interpretation(
        Date.day.custom(int)
    ),
    MONTH_NAME.interpretation(
        Date.month.normalized().custom(MONTHS.get)
    ),
    YEAR.interpretation(
        Date.year.custom(int)
    )
).interpretation(
    Date
)

parser = Parser(DATE)
match = parser.match('18 июня 2016')
match.fact
Out[19]:
Date(
    year=2016,
    month=6,
    day=18
)

Подробнее в справочнике.

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

Примитивная грамматика имён:

In [20]:
NAME = rule(
    gram('Name').interpretation(
        Name.first.inflected()
    ),
    gram('Surn').interpretation(
        Name.last.inflected()
    )
).interpretation(
    Name
)

У неё есть две проблемы. Она срабатывает на словосочетаниях, где имя и фамилия в разных падежах:

In [21]:
parser = Parser(NAME)
for match in parser.findall('Илье Ивановым, Павлом Семенов'):
    print([_.value for _ in match.tokens])
['Илье', 'Ивановым']
['Павлом', 'Семенов']

Имя и фамилия приводятся к нормальной форме независимо, получается женщина "Иванов":

In [22]:
parser = Parser(NAME)
for match in parser.findall('Сашу Иванову, Саше Иванову'):
    display(match.fact)
Name(
    first='саша',
    last='иванов'
)
Name(
    first='саша',
    last='иванов'
)

В Yargy связь между словами и словосочетаниями устанавливается методом match. Для согласования по числу в match передаём number_relation, для согласования по падежу, роду и числу — gnc_relation:

In [23]:
from yargy.relations import gnc_relation


gnc = gnc_relation()

NAME = rule(
    gram('Name').interpretation(
        Name.first.inflected()
    ).match(gnc),
    gram('Surn').interpretation(
        Name.last.inflected()
    ).match(gnc)
).interpretation(
    Name
)
In [24]:
parser = Parser(NAME)
for match in parser.findall('Илье Ивановым, Павлом Семенов, Саша Быков'):
    print([_.value for _ in match.tokens])
['Саша', 'Быков']
In [25]:
parser = Parser(NAME)
for match in parser.findall('Сашу Иванову, Саше Иванову'):
    display(match.fact)
Name(
    first='саша',
    last='иванова'
)
Name(
    first='саша',
    last='иванов'
)

Список согласований в справочнике.

In [ ]: