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)
Грамматики для имён собраны в репозитории 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)])
Для каждого токена Pymorph2 возвращает набор граммем. Например, "NOUN, sing, femn" — "существительное в единственном числе женского рода". Полный список в документации Pymorph2.
Вне контекста слово имеет несколько вариантов разбора. Например, "стали" — глагол (VERB) во фразе "мы стали лучше" и существительное (NOUN) в "марки стали":
In [4]:
tokenizer = MorphTokenizer()
list(tokenizer('марки стали'))
Out[4]:
Токенизатор работает на правилах. В справочнике показано, как менять стандартные правила и добавлять новые.
Предикат принимает токен, возвращает 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])
Список газеттиров в справочнике.
В 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]:
В 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]:
Как быть, когда правая часть правила ссылается на левую? Например:
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]:
Рекурсивные правила описывают последовательности токенов произвольной длины. Грамматика для текста в кавычках:
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]:
Для удобства в Yargy есть метод repeatable
с ним запись короче. Библиотека автоматически добавит forward
:
In [13]:
TITLE = rule(
'«',
not_(eq('»')).repeatable(),
'»'
).named('TITLE')
TITLE.normalized.as_bnf
Out[13]:
У парсера есть два метода: 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)
Результат работы парсера — это дерево разбора. Грамматика и деревья разбора для дат:
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)
Интерпретация — процесс преобразования дерева разбора в объект с набором полей. Для даты, например, нужно получить структуры вида 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)
Из размеченного дерева библиотека собирает объект:
In [18]:
for line in text.splitlines():
match = parser.match(line)
display(match.fact)
Подробнее об интерпретации в справочнике.
Содержание полей фактов нужно нормировать. Например, не 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]:
Подробнее в справочнике.
Примитивная грамматика имён:
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)
В 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)
Список согласований в справочнике.
In [ ]: