Морфологический анализатор pymorphy2

pymorphy2 написан на языке Python (работает под 2.7 и 3.5+). Он умеет:

  1. приводить слово к нормальной форме (например, “люди -> человек”, или “гулял -> гулять”).
  2. ставить слово в нужную форму. Например, ставить слово во множественное число, менять падеж слова и т.д.
  3. возвращать грамматическую информацию о слове (число, род, падеж, часть речи и т.д.)

При работе используется словарь OpenCorpora; для незнакомых слов строятся гипотезы. Библиотека достаточно быстрая: в настоящий момент скорость работы - от нескольких тыс слов/сек до > 100тыс слов/сек (в зависимости от выполняемой операции, интерпретатора и установленных пакетов); потребление памяти - 10…20Мб; полностью поддерживается буква ё.

Лицензия - MIT. Если вы используете pymorphy2 в научной работе, см. также раздел Цитирование.

Содержание

Документация

Important

в примерах используется синтаксис Python 3.

Important

в примерах используется синтаксис Python 3.

Руководство пользователя

Установка

Для установки воспользуйтесь pip:

pip install pymorphy2

Чтоб установить оптимизированную версию, используйте следующую команду:

pip install pymorphy2[fast]

Оптимизированная версия может требовать настроенного окружения для сборки (компилятора C/C++ и т.д.).

Словари распространяются отдельными пакетами:

  • pymorphy2-dicts-ru для русского языка,
  • pymorphy2-dicts-uk для украинского языка (экспериментальный).

Они обновляются время от времени; чтоб обновить словари, используйте

pip install -U pymorphy2-dicts-ru
pip install -U pymorphy2-dicts-uk

Для установки требуются более-менее современные версии pip и setuptools.

Морфологический анализ

Морфологический анализ - это определение характеристик слова на основе того, как это слово пишется. При морфологическом анализе не используется информация о соседних словах.

В pymorphy2 для морфологического анализа слов есть класс MorphAnalyzer.

>>> import pymorphy2
>>> morph = pymorphy2.MorphAnalyzer()

По умолчанию используется словарь для русского языка; чтобы вместо русского включить украинский словарь, с помощью pip установите пакет pymorphy2-dicts-uk и используйте

>>> morph = pymorphy2.MorphAnalyzer(lang='uk')

Экземпляры класса MorphAnalyzer обычно занимают порядка 15Мб оперативной памяти (т.к. загружают в память словари, данные для предсказателя и т.д.); старайтесь организовать свой код так, чтобы создавать экземпляр MorphAnalyzer заранее и работать с этим единственным экземпляром в дальнейшем.

С помощью метода MorphAnalyzer.parse() можно разобрать отдельное слово:

>>> morph.parse('стали')
[Parse(word='стали', tag=OpencorporaTag('VERB,perf,intr plur,past,indc'), normal_form='стать', score=0.983766, methods_stack=((<DictionaryAnalyzer>, 'стали', 884, 4),)),
 Parse(word='стали', tag=OpencorporaTag('NOUN,inan,femn sing,gent'), normal_form='сталь', score=0.003246, methods_stack=((<DictionaryAnalyzer>, 'стали', 12, 1),)),
 Parse(word='стали', tag=OpencorporaTag('NOUN,inan,femn sing,datv'), normal_form='сталь', score=0.003246, methods_stack=((<DictionaryAnalyzer>, 'стали', 12, 2),)),
 Parse(word='стали', tag=OpencorporaTag('NOUN,inan,femn sing,loct'), normal_form='сталь', score=0.003246, methods_stack=((<DictionaryAnalyzer>, 'стали', 12, 5),)),
 Parse(word='стали', tag=OpencorporaTag('NOUN,inan,femn plur,nomn'), normal_form='сталь', score=0.003246, methods_stack=((<DictionaryAnalyzer>, 'стали', 12, 6),)),
 Parse(word='стали', tag=OpencorporaTag('NOUN,inan,femn plur,accs'), normal_form='сталь', score=0.003246, methods_stack=((<DictionaryAnalyzer>, 'стали', 12, 9),))]

Note

если используете Python 2.x, то будьте внимательны - юникодные строки пишутся как u'стали'.

Метод MorphAnalyzer.parse() возвращает один или несколько объектов типа Parse с информацией о том, как слово может быть разобрано.

В приведенном примере слово “стали” может быть разобрано и как глагол (“они стали лучше справляться”), и как существительное (“кислородно-конверторный способ получения стали”). На основе одной лишь информации о том, как слово пишется, понять, какой разбор правильный, нельзя, поэтому анализатор может возвращать несколько вариантов разбора.

У каждого разбора есть тег:

>>> p = morph.parse('стали')[0]
>>> p.tag
OpencorporaTag('VERB,perf,intr plur,past,indc')

Тег - это набор граммем, характеризующих данное слово. Например, тег 'VERB,perf,intr plur,past,indc' означает, что слово - глагол (VERB) совершенного вида (perf), непереходный (intr), множественного числа (plur), прошедшего времени (past), изъявительного наклонения (indc).

Доступные граммемы описаны тут: Обозначения для граммем (русский язык).

Кроме того, у каждого разбора есть нормальная форма, которую можно получить, обратившись к атрибутам normal_form или normalized:

>>> p.normal_form
'стать'
>>> p.normalized
Parse(word='стать', tag=OpencorporaTag('INFN,perf,intr'), normal_form='стать', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'стать', 884, 0),))

pymorphy2 умеет разбирать не только словарные слова; для несловарных слов автоматически задействуется предсказатель. Например, попробуем разобрать слово “бутявковедами” - pymorphy2 поймет, что это форма творительного падежа множественного числа существительного “бутявковед”, и что “бутявковед” - одушевленный и мужского рода:

>>> morph.parse('бутявковедами')
[Parse(word='бутявковедами', tag=OpencorporaTag('NOUN,anim,masc plur,ablt'), normal_form='бутявковед', score=1.0, methods_stack=((<FakeDictionary>, 'бутявковедами', 51, 10), (<KnownSuffixAnalyzer>, 'едами')))]
Работа с тегами

Для того, чтоб проверить, есть ли в данном теге отдельная граммема (или все граммемы из указанного множества), используйте оператор in:

>>> p.tag
OpencorporaTag('VERB,perf,intr plur,past,indc')
>>> 'NOUN' in p.tag  # то же самое, что и {'NOUN'} in p.tag
False
>>> 'VERB' in p.tag
True
>>> {'VERB'} in p.tag
True
>>> {'plur', 'past'} in p.tag
True
>>> {'NOUN', 'plur'} in p.tag
False

Кроме того, у каждого тега есть атрибуты, через которые можно получить часть речи, число и другие характеристики:

>>> p.tag
OpencorporaTag('VERB,perf,intr plur,past,indc')
>>> p.tag.POS           # Part of Speech, часть речи
'VERB'
>>> p.tag.animacy       # одушевленность
None
>>> p.tag.aspect        # вид: совершенный или несовершенный
'perf'
>>> p.tag.case          # падеж
None
>>> p.tag.gender        # род (мужской, женский, средний)
None
>>> p.tag.involvement   # включенность говорящего в действие
None
>>> p.tag.mood          # наклонение (повелительное, изъявительное)
'indc'
>>> p.tag.number        # число (единственное, множественное)
'plur'
>>> p.tag.person        # лицо (1, 2, 3)
None
>>> p.tag.tense         # время (настоящее, прошедшее, будущее)
'past'
>>> p.tag.transitivity  # переходность (переходный, непереходный)
'intr'
>>> p.tag.voice         # залог (действительный, страдательный)
None

Если запрашиваемая характеристика для данного тега не определена, то возвращается None.

В написании граммем достаточно просто ошибиться; для борьбы с ошибками pymorphy2 выкидывает исключение, если встречает недопустимую граммему:

>>> 'foobar' in p.tag
Traceback (most recent call last):
...
ValueError: Grammeme is unknown: foobar
>>> {'NOUN', 'foo', 'bar'} in p.tag
Traceback (most recent call last):
...
ValueError: Grammemes are unknown: {'bar', 'foo'}

Это работает и для атрибутов:

>>> p.tag.POS == 'plur'
Traceback (most recent call last):
...
ValueError: 'plur' is not a valid grammeme for this attribute.
Кириллические названия тегов и граммем

Теги и граммемы в pymorphy2 записываются латиницей (например, NOUN). Но часто удобнее использовать кириллические названия граммем (например, СУЩ вместо NOUN). Чтобы получить тег в виде строки, записанной кириллицей, используйте свойство OpencorporaTag.cyr_repr:

>>> p.tag
OpencorporaTag('VERB,perf,intr plur,past,indc')
>>> p.tag.cyr_repr
'ГЛ,сов,неперех мн,прош,изъяв'

Для преобразования произвольных строк с тегами/граммемами между кириллицей и латиницей используйте методы MorphAnalyzer.cyr2lat() и MorphAnalyzer.lat2cyr():

>>> morph.lat2cyr('NOUN,anim,masc plur,ablt')
'СУЩ,од,мр мн,тв'
>>> morph.cyr2lat('СУЩ,од,мр мн,тв')
'NOUN,anim,masc plur,ablt'
Склонение слов

pymorphy2 умеет склонять (ставить в какую-то другую форму) слова. Чтобы просклонять слово, нужно сначала понять, в какой форме оно стоит в настоящий момент и какая у него лексема. Другими словами, нужно сперва разобрать слово и выбрать из предложенных вариантов разбора правильный.

Для примера разберем слово “бутявка” и возьмем первый вариант разбора:

>>> butyavka = morph.parse('бутявка')[0]
>>> butyavka
Parse(word='бутявка', tag=OpencorporaTag('NOUN,inan,femn sing,nomn'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явка', 8, 0), (<UnknownPrefixAnalyzer>, 'бут')))

Получив объект Parse, можно просклонять слово, используя его метод Parse.inflect():

>>> butyavka.inflect({'gent'})  # нет кого? (родительный падеж)
Out[13]:
Parse(word='бутявки', tag=OpencorporaTag('NOUN,inan,femn sing,gent'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явки', 8, 1), (<UnknownPrefixAnalyzer>, 'бут')))
>>> butyavka.inflect({'plur', 'gent'})  # кого много?
Parse(word='бутявок', tag=OpencorporaTag('NOUN,inan,femn plur,gent'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явок', 8, 8), (<UnknownPrefixAnalyzer>, 'бут')))

С помощью атрибута Parse.lexeme можно получить лексему слова:

>>> butyavka.lexeme
[Parse(word='бутявка', tag=OpencorporaTag('NOUN,inan,femn sing,nomn'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явка', 8, 0), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявки', tag=OpencorporaTag('NOUN,inan,femn sing,gent'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явки', 8, 1), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявке', tag=OpencorporaTag('NOUN,inan,femn sing,datv'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явке', 8, 2), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявку', tag=OpencorporaTag('NOUN,inan,femn sing,accs'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явку', 8, 3), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявкой', tag=OpencorporaTag('NOUN,inan,femn sing,ablt'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явкой', 8, 4), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявкою', tag=OpencorporaTag('NOUN,inan,femn sing,ablt,V-oy'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явкою', 8, 5), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявке', tag=OpencorporaTag('NOUN,inan,femn sing,loct'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явке', 8, 6), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявки', tag=OpencorporaTag('NOUN,inan,femn plur,nomn'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явки', 8, 7), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявок', tag=OpencorporaTag('NOUN,inan,femn plur,gent'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явок', 8, 8), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявкам', tag=OpencorporaTag('NOUN,inan,femn plur,datv'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явкам', 8, 9), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявки', tag=OpencorporaTag('NOUN,inan,femn plur,accs'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явки', 8, 10), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявками', tag=OpencorporaTag('NOUN,inan,femn plur,ablt'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явками', 8, 11), (<UnknownPrefixAnalyzer>, 'бут'))),
 Parse(word='бутявках', tag=OpencorporaTag('NOUN,inan,femn plur,loct'), normal_form='бутявка', score=1.0, methods_stack=((<DictionaryAnalyzer>, 'явках', 8, 12), (<UnknownPrefixAnalyzer>, 'бут')))]
Постановка слов в начальную форму

Нормальную (начальную) форму слова можно получить через атрибуты Parse.normal_form и Parse.normalized. Чтоб получить объект Parse, нужно сперва разобрать слово и выбрать правильный вариант разбора из предложенных.

Но что считается за нормальную форму? Например, возьмем слово “думающим”. Иногда мы захотим нормализовать его в “думать”, иногда - в “думающий”, иногда - в “думающая”.

Посмотрим, что сделает pymorphy2 в этом примере:

>>> morph.parse('думающему')[0].normal_form
'думать'

pymorphy2 сейчас использует алгоритм нахождения нормальной формы, который работает наиболее быстро (берется первая форма в лексеме) - поэтому, например, все причастия сейчас нормализуются в инфинитивы. Это можно считать деталью реализации.

Если требуется нормализовывать слова иначе, можно воспользоваться методом Parse.inflect():

>>> morph.parse('думающему')[0].inflect({'sing', 'nomn'}).word
'думающий'
Согласование слов с числительными

Слово нужно ставить в разные формы в зависимости от числительного, к которому оно относится. Например: “1 бутявка”, “2 бутявки”, “5 бутявок”

Для этих целей используйте метод Parse.make_agree_with_number():

>>> butyavka = morph.parse('бутявка')[0]
>>> butyavka.make_agree_with_number(1).word
'бутявка'
>>> butyavka.make_agree_with_number(2).word
'бутявки'
>>> butyavka.make_agree_with_number(5).word
'бутявок'
Выбор правильного разбора

pymorphy2 возвращает все допустимые варианты разбора, но на практике обычно нужен только один вариант, правильный.

У каждого разбора есть параметр score:

>>> morph.parse('на')
[Parse(word='на', tag=OpencorporaTag('PREP'), normal_form='на', score=0.999628, methods_stack=((<DictionaryAnalyzer>, 'на', 23, 0),)),
 Parse(word='на', tag=OpencorporaTag('INTJ'), normal_form='на', score=0.000318, methods_stack=((<DictionaryAnalyzer>, 'на', 20, 0),)),
 Parse(word='на', tag=OpencorporaTag('PRCL'), normal_form='на', score=5.3e-05, methods_stack=((<DictionaryAnalyzer>, 'на', 21, 0),))]

score - это оценка P(tag|word), оценка вероятности того, что данный разбор правильный.

Note

Оценка P(tag|word) пока недоступна в украинском словаре.

Условная вероятность P(tag|word) оценивается на основе корпуса OpenCorpora: ищутся все неоднозначные слова со снятой неоднозначностью, для каждого слова считается, сколько раз ему был сопоставлен данный тег, и на основе этих частот вычисляется условная вероятность тега (с использованием сглаживания Лапласа).

На данный момент оценки P(tag|word) на основе OpenCorpora есть примерно для 20 тыс. слов (исходя из примерно 250тыс. наблюдений). Для тех слов, для которых такой оценки нет, вероятность P(tag|word) либо считается равномерной (для словарных слов), либо оценивается на основе эмпирических правил (для несловарных слов).

На практике это означает, что первый разбор из тех, что возвращают методы MorphAnalyzer.parse() и MorphAnalyzer.tag(), более вероятен, чем остальные. Для слов (без учета пунктуации и т.д.) цифры такие:

  • случайно выбранный разбор (из допустимых) верен примерно в 66% случаев;
  • первый по словарю разбор (pymorphy2 < 0.4) верен примерно в 72% случаев;
  • разбор, который выдает pymorphy2 == 0.4, выбранный на основе оценки P(tag|word), верен примерно в 79% случаев.

Разборы сортируются по убыванию score, поэтому везде в примерах берется первый вариант разбора из возможных (например, morph.parse('бутявка')[0]).

Оценки P(tag|word) помогают улучшить разбор, но их недостаточно для надежного снятия неоднозначности, как минимум по следующим причинам:

  • то, как нужно разбирать слово, зависит от соседних слов; pymorphy2 работает только на уровне отдельных слов;
  • условная вероятность P(tag|word) оценена на основе сбалансированного набора текстов; в специализированных текстах вероятности могут быть другими - например, возможно, что в металлургических текстах P(NOUN|стали) > P(VERB|стали);
  • в OpenCorpora у большинства слов неоднозначность пока не снята; выполняя задания на сайте OpenCorpora, можно непосредственно помочь улучшить оценку P(tag|word) и, следовательно, качество работы pymorphy2.

Если вы берете первый разбор из возможных (как в примерах), то стоит учитывать эту проблему.

Иногда могут помочь какие-то особенности задачи. Например, если нужно просклонять слово, и известно, что на входе ожидается слово в именительном падеже, то лучше брать вариант разбора в именительном падеже, а не первый. В общем же случае для выбора точного разбора необходимо каким-то образом учитывать не только само слово, но и другие слова в предложении.

Обозначения для граммем (русский язык)

В pymorphy2 для русского языка используются словари OpenCorpora и граммемы, принятые в OpenCorpora (с небольшими изменениями).

Полный список граммем OpenCorpora доступен тут: http://opencorpora.org/dict.php?act=gram

Часть речи
Граммема Значение Примеры
NOUN имя существительное хомяк
ADJF имя прилагательное (полное) хороший
ADJS имя прилагательное (краткое) хорош
COMP компаратив лучше, получше, выше
VERB глагол (личная форма) говорю, говорит, говорил
INFN глагол (инфинитив) говорить, сказать
PRTF причастие (полное) прочитавший, прочитанная
PRTS причастие (краткое) прочитана
GRND деепричастие прочитав, рассказывая
NUMR числительное три, пятьдесят
ADVB наречие круто
NPRO местоимение-существительное он
PRED предикатив некогда
PREP предлог в
CONJ союз и
PRCL частица бы, же, лишь
INTJ междометие ой

Часть речи можно получить через атрибут POS:

>>> p = morph.parse('идти')[0]
>>> p.tag.POS
'INFN'
Падеж
Граммема Значение Пояснение Примеры
nomn именительный Кто? Что? хомяк ест
gent родительный Кого? Чего? у нас нет хомяка
datv дательный Кому? Чему? сказать хомяку спасибо
accs винительный Кого? Что? хомяк читает книгу
ablt творительный Кем? Чем? зерно съедено хомяком
loct предложный О ком? О чём? и т.п. хомяка несут в корзинке
voct звательный Его формы используются при обращении к человеку. Саш, пойдем в кино.
gen2 второй родительный (частичный)   ложка сахару (gent - производство сахара); стакан яду (gent - нет яда)
acc2 второй винительный   записался в солдаты
loc2 второй предложный (местный)   я у него в долгу (loct - напоминать о долге); висит в шкафу (loct - монолог о шкафе); весь в снегу (loct - писать о снеге)

Падеж выделяется у существительных, полных прилагательных, полных причастий, числительных и местоимений. Получить его можно через атрибут case:

>>> p = morph.parse('хомяку')[0]
>>> p.tag.case
'datv'

Note

В OpenCorpora (на июль 2013) есть еще падежи gen1 и loc1. Они указываются вместо gent/loct, когда у слова есть форма gen2/loc2. В pymorphy2 gen1 и loc1 заменены на gent/loct, чтоб с ними было проще работать.

Число
Граммема Значение Примеры
sing единственное число хомяк, говорит
plur множественное число хомяки, говорят
>>> p = morph.parse('люди')[0]
>>> p.tag.number
'plur'

Некоторые имена существительные употребляются только во множественном числе; им проставлена пометка Pltm (“Pluralia tantum”):

>>> morph.parse('дрова')[0].tag
OpencorporaTag('NOUN,inan,GNdr,Pltm plur,accs')

Существуют также существительные, употребляемые только в единственном числе; им проставлена пометка Sgtm (“Singularia tantum”):

>>> morph.parse('молоко')[0].tag
OpencorporaTag('NOUN,inan,neut,Sgtm sing,nomn')

Ни Sgtm, ни Pltm не являются значениями p.tag.number.

Род
Граммема Значение Примеры
masc мужской род хомяк, говорил
femn женский род хомячиха, говорила
neut средний род зерно, говорило
>>> p = morph.parse('зерно')[0]
>>> p.tag.gender
'neut'

В русском языке существует понятие “общего рода”; некоторые слова могут употребляться применительно к людям мужского или женского пола: “он бедный сирота”, “она бедная сирота”. Таким словам проставлена пометка Ms-f:

>>> p = morph.parse('сирота')[0]
>>> 'Ms-f' in p.tag
True

Существуют также существительные, у которых род не выражен; им проставлена пометка GNdr. Ни Ms-f, ни GNdr не является значением p.tag.gender.

Нестандартные граммемы

В pymorphy2 используются некоторые граммемы, отсутствующие в словаре OpenCorpora:

Граммема Значение
LATN Токен состоит из латинских букв (например, “foo-bar” или “Maßstab”)
PNCT Пунктуация (например, , или !? или )
NUMB Число (например, “204” или “3.14”)
intg целое число (например, “204”)
real вещественное число (например, “3.14”)
ROMN Римское число (например, XI)
UNKN Токен не удалось разобрать

Пример:

>>> p = morph.parse('...')[0]
>>> p.tag
OpencorporaTag('PNCT')

Как принять участие в разработке

Общая информация

Исходный код pymorphy2 распространяется по лицензии MIT и доступен на github: https://github.com/kmike/pymorphy2

Баг-трекер: https://github.com/kmike/pymorphy2/issues. Для общения можно использовать гугл-группу (есть какие-то идеи, предложения, замечания - пишите).

Если вы хотите улучшить код pymorphy2 - может быть полезным ознакомиться с разделом Внутреннее устройство.

pymorphy2 работает под Python 2.x и 3.x без использования утилиты 2to3; написание такого кода, по опыту, оказывается не сложнее написания кода просто под 2.х, но поначалу требует некоторой внимательности и осторожности. Пожалуйста, пишите и запускайте тесты, если что-то меняете.

Улучшать можно не только код - улучшения в документации, идеи и сообщения об ошибках тоже очень ценны.

Словари

Поддержка русского языка в pymorphy2 основывается на словарях из OpenCorpora и использует наборы текстов оттуда для автоматического тестирования и замеров скорости; в будущем планируется также использовать размеченный корпус для снятия неоднозначности разбора, ну и в целом это классный проект. Любая помощь OpenCorpora - это вклад и в pymorphy2.

Экспериментальный украинский словарь корнями уходит в проект LanguageTool; отдельно он доступен тут: https://github.com/arysin/dict_uk, скрипты для преобразования в формат OpenCorpora - тут: https://github.com/dchaplinsky/LT2OpenCorpora.

Все словари преобразуются в формат pymorphy2 скриптами отсюда: https://github.com/kmike/pymorphy2-dicts

Тестирование

Тесты лежат в папке tests. При написании тестов используется pytest. Для их запуска используется утилита tox, которая позволяет выполнять тесты для нескольких интерпретаторов питона.

Для запуска тестов установите tox через pip:

pip install tox

и выполните

tox

из папки с исходным кодом.

Замеры скорости работы

Код для бенчмарков лежит в папке benchmarks. Для запуска тестов производительности выполните

tox -c bench.ini

из папки с исходным кодом pymorphy2.

Внутреннее устройство

Словари

В pymorphy2 используются словари из проекта OpenCorpora, специальным образом обработанные для быстрых выборок.

Исходный словарь

Исходный словарь из OpenCorpora представляет собой файл, в котором слова объединены в лексемы следующим образом:

ёж      NOUN,anim,masc sing,nomn
ежа     NOUN,anim,masc sing,gent
ежу     NOUN,anim,masc sing,datv
ежа     NOUN,anim,masc sing,accs
ежом    NOUN,anim,masc sing,ablt
еже     NOUN,anim,masc sing,loct
ежи     NOUN,anim,masc plur,nomn
ежей    NOUN,anim,masc plur,gent
ежам    NOUN,anim,masc plur,datv
ежей    NOUN,anim,masc plur,accs
ежами   NOUN,anim,masc plur,ablt
ежах    NOUN,anim,masc plur,loct

Лексема состоит из всех форм слова, причем для каждой формы указана грамматическая информация (тег). Первой формой в списке идет нормальная форма слова.

Две основные операции, которые умеет делать морфологический анализатор - разбор и склонение слов. Если у нас есть словарь с лексемами, и мы хотим разобрать/просклонять словарное слово, то эти операции очень простые:

  • разобрать слово - найти его в словаре и вернуть приписанную ему грамматическую информацию;
  • просклонять слово - найти слово в словаре, определить его лексему, а затем найти в лексеме нужное слово с запрошенными грамматическими характеристиками.

Если все, что нужно - разбор словарных слов, то можно загрузить все слова и их грамматическую информацию в память “как есть”, либо сохранить в какую-то БД общего назначения. С этим есть 2 проблемы:

  • в словаре OpenCorpora для русского языка около 400тыс. лексем и 5млн отдельных слов; если все загрузить в питоний list, то потратим примерно 2Гб оперативной памяти (в dict - еще больше);
  • хотелось бы, чтоб операции по анализу и склонению слов осуществлялись быстро - в том числе для слов, отсутствующих в словаре, и слов, записанных каким-то “упрощенным” способом (например, с буквой е вместо ё).

Чтобы сэкономить оперативную память и обеспечить быстрый анализ как словарных, так и несловарных слов, pymorphy2:

  • извлекает “парадигмы” из лексем;
  • преобразует информацию в цифры, когда можно;
  • кодирует слова в DAFSA.
Извлечение парадигм

Рассмотрим лексему слова “хомяковый”:

  СЛОВО       ТЕГ
  хомяковый   ADJF,Qual masc,sing,nomn
  хомякового  ADJF,Qual masc,sing,gent
              ...
  хомяковы    ADJS,Qual plur
  хомяковее   COMP,Qual
  хомяковей   COMP,Qual V-ej
похомяковее   COMP,Qual Cmp2
похомяковей   COMP,Qual Cmp2,V-ej

Можно заметить, что каждое слово в лексеме можно разбить на 3 части - “префикс”, “стем” и “хвост”:

ПРЕФИКС СТЕМ      ХВОСТ   ТЕГ
        хомяков   ый      ADJF,Qual masc,sing,nomn
        хомяков   ого     ADJF,Qual masc,sing,gent
                  ...
        хомяков   ы       ADJS,Qual plur
        хомяков   ее      COMP,Qual
        хомяков   ей      COMP,Qual V-ej
    по  хомяков   ее      COMP,Qual Cmp2
    по  хомяков   ей      COMP,Qual Cmp2,V-ej

“Стем” тут - часть слова, общая для всех слов в лексеме. Откинем его:

ПРЕФИКС           ХВОСТ   ТЕГ
                  ый      ADJF,Qual masc,sing,nomn
                  ого     ADJF,Qual masc,sing,gent
                  ...
                  ы       ADJS,Qual plur
                  ее      COMP,Qual
                  ей      COMP,Qual V-ej
    по            ее      COMP,Qual Cmp2
    по            ей      COMP,Qual Cmp2,V-ej

По этой новой таблице можно склонять не только слово “хомяковый”, но и другие слова - например, “красивый” или даже “бутявковый”. Полученная таблица - это и есть парадигма, образец для склонения или спряжения слов.

При компиляции словаря OpenCorpora pymorphy2 для каждой лексемы определяет ее парадигму. Для русского языка получается примерно 3 тысячи уникальных парадигм (из примерно 400 тысяч лексем).

Имея парадигмы, не нужно хранить все лексемы и информацию о том, к какой лексеме принадлежит слово - достаточно сохранить парадигмы и информацию о том, по какой парадигме слово изменяется.

Хранение парадигм

Чтобы хранить парадигмы более компактно, они преобразуются в массивы чисел. Префиксам, “хвостам” и тегам присваиваются номера, и в парадигмах хранятся только эти номера. Строки с префиксами, хвостами и тегами хранятся отдельно, в питоньих list. Номер строки - это просто ее индекс.

Пример закодированной таким образом парадигмы:

prefix_id        suffix_id   tag_id
        0        66          78
        0        67          79
                 ...
        0        37          94
        0        82          95
        0        121         96
        1        82          97
        1        121         98

Каждая парадигма упаковывается в одномерный массив (array.array): сначала идут все номера хвостов, потом все номера тегов, потом все номера префиксов:

66 67 ... 37 82 121 82 121 | 78 79 ... 94 95 96 97 98 | 0 0 ... 0 0 0 1 1

Пусть парадигма состоит из N форм слов; в массиве будет тогда N*3 элементов. Данные о i-й форме можно получить с помощью индексной арифметики: например, номер грамматической информации для формы с индексом 2 (индексация с 0) будет лежать в элементе массива с номером N + 2, а номер префикса для этой же формы - в элементе N*2 + 2.

Note

Особенности реализации в Python:

Тройки “номер хвоста, номер грамматической информации, номер префикса” в tuple хранить расточительно, т.к. этих троек получается очень много (сотни тысяч), а каждый tuple требует дополнительной памяти:

>>> import sys
>>> sys.getsizeof(tuple())
56

В отличие от питоньего list, array.array хранится одним куском памяти, накладные расходы меньше. В питоне list - массив указателей на объекты.

Строки кодируются в цифры, чтобы их можно было хранить в array.array, и чтобы не хранить одну и ту же строку много раз (в питоне не гарантировано, что id(string1) == id(string2), если string1 == string2).

Связи между лексемами

В словаре OpenCorpora доступна информация о связях между лексемами. Например, может быть связана лексема для инфинитива и лексема с формами глагола, соответствующими этому инфинитиву. Или, например, формы краткого и полного прилагательного.

Эта информация позволяет склонять слова между частями речи (например, причастие приводить к глаголу).

В pymorphy2 все связанные лексемы просто объединяются в одну большую лексему на этапе подготовки (компиляции) исходного словаря; в скомпилированном словаре информация о связях между лексемами в явном виде недоступна.

Упаковка слов

Для хранения данных о словах используется конечный автомат (Deterministic Acyclic Finite State Automaton, wiki) с использованием библиотек DAWG (это обертка над C++ библиотекой dawgdic) или DAWG-Python (это написанная на питоне реализация DAWG, которая не требует компилятора для установки и работает быстрее DAWG под PyPy).

В структуре данных DAFSA некоторые общие части слов не дублируются (=> требуется меньше памяти); кроме того, в DAWG можно быстро выполнять не только точный поиск слова, но и другие операции - например, поиск по префиксу или поиск с заменами.

В pymorphy2 в DAWG помещаются не сами слова, а строки вида

<слово> <разделитель> <номер парадигмы> <номер формы в парадигме>

Пусть, для примера, у нас есть слова (в скобках - допустимые разборы, определяемые парами “номер парадигмы, номер формы в парадигме”).

двор    (103, 0)
ёж      (104, 0)
дворник (101, 2) и (102, 2)
ёжик    (101, 2) и (102, 2)

Тогда они будут закодированы в такой граф:

digraph foo { rankdir=LR; size=9; node [shape = doublecircle]; 10 14; node [shape = circle]; 0 -> 2 [label=Д]; 0 -> 3 [label=Ё]; 1 -> 4 [label=О]; 2 -> 1 [label=В]; 3 -> 16 [label=Ж]; 4 -> 6 [label=Р]; 5 -> 8 [label=К]; 6 -> 7 [label=Н]; 6 -> 22 [label=sep]; 7 -> 5 [label=И]; 8 -> 9 [label=sep]; 9 -> 12 [label="103"]; 9 -> 15 [label="102"]; 12 -> 10 [label="2"]; 13 -> 14 [label="0"]; 15 -> 10 [label="2"]; 16 -> 32 [label=И]; 16 -> 54 [label=sep]; 17 -> 14 [label="2"]; 22 -> 13 [label="103"]; 32 -> 8 [label=К]; 54 -> 17 [label="104"]; }

Этот подход позволяет экономить память (т.к. как сами слова, так и данные о парадигмах и индексах сжимаются в DAWG), + алгоритмы упрощаются: например, для получения всех возможных вариантов разбора слова достаточно найти все ключи, начинающиеся с

<слово> <разделитель>

– а эта операция (поиск всех ключей по префиксу) в используемой реализации DAWG достаточно эффективная. Хранение слов в DAWG позволяет также быстро и правильно обрабатывать букву “ё”.

Note

На самом деле граф будет немного не такой, т.к. текст кодируется в utf-8, а значения в base64, и поэтому узлов будет больше; для получения одной буквы или цифры может требоваться совершить несколько переходов.

Кодировка utf-8 используется из-за того, что кодек utf-8 в питоне в несколько раз быстрее однобайтового cp1251. Кодировка цифр в base64 - тоже деталь реализации: C++ библиотека, на которой основан DAWG, поддерживает только нуль-терминированные строки. Байт 0 считается завершением строки и не может присутствовать в ключе, а для двухбайтовых целых чисел сложно гарантировать, что оба байта ненулевые.

Note

Подход похож на тот, что описан на aot.ru.

Итоговый формат данных
Таблица с грамматической информацией
['tag1', 'tag2', ...]

tag<N> - тег (грамматическая информация, набор граммем): например, NOUN,anim,masc sing,nomn.

Этот массив занимает где-то 0.5M памяти.

Парадигмы
paradigms = [
    array.array("<H", [
        suff_id1, .., suff_idN,
        tag_id1, .., tag_idN,
        pref_id1, .., pref_idN
    ]),

    array.array("<H", [
        ...
    ]),

    ...
]

suffixes = ['suffix1', 'suffix2', ...]
prefixes = ['prefix1', 'prefix2', ...]

suff_id<N>, tag_id<N> и pref_id<N> - это индексы в таблицах с возможными “окончаниями” suffixes, грамматической информацией (тегами) и “префиксами” prefixes соответственно.

Парадигмы и соответствующие списки “окончаний” и “префиксов” занимают примерно 3-4M памяти.

Слова

Все слова хранятся в dawg.RecordDAWG:

dawg.RecordDAWG

    'word1': (para_id1, para_index1),
    'word1': (para_id2, para_index2),
    'word2': (para_id1, para_index1),
    ...

В DAWG эта информация занимает примерно 7M памяти.

Алгоритм разбора по словарю

С описанной выше структурой словаря разбирать известные слова достаточно просто. Код на питоне:

result = []

# Ищем в DAWG со словами все ключи, которые начинаются
# с <СЛОВО><sep> (обходом по графу); из этих ключей (из того, что за <sep>)
# получаем список кортежей [(para_id1, index1), (para_id2, index2), ...].
#
# RecordDAWG из библиотек DAWG или DAWG-Python умеет это делать
# одной командой (с возможностью нечеткого поиска для буквы Ё):

para_data = self._dictionary.words.similar_items(word, self._ee)

# fixed_word - это слово с исправленной буквой Ё, для которого был
# проведен разбор.

for fixed_word, parse in para_data:
    for para_id, idx in parse:

        # по информации о номере парадигмы и номере слова в
        # парадигме восстанавливаем нормальную форму слова и
        # грамматическую информацию.

        tag = self._build_tag_info(para_id, idx)
        normal_form = self._build_normal_form(para_id, idx, fixed_word)

        result.append(
            (fixed_word, tag, normal_form)
        )

Настоящий код немного отличается в деталях, но суть та же.

Т.к. парадигмы запакованы в линейный массив, требуются дополнительные шаги для получения данных. Метод _build_tag_info реализован, например, вот так:

def _build_tag_info(self, para_id, idx):

    # получаем массив с данными парадигмы
    paradigm = self._dictionary.paradigms[para_id]

    # индексы грамматической информации начинаются со второй трети
    # массива с парадигмой
    tag_info_offset = len(paradigm) // 3

    # получаем искомый индекс
    tag_id = paradigm[tag_info_offset + tag_id_index]

    # возвращаем соответствующую строку из таблицы с грамматической информацией
    return self._dictionary.gramtab[tag_id]

Note

Для разбора слов, которых нет в словаре, в pymorphy2 есть предсказатель.

Формат хранения словаря

Итоговый словарь представляет собой папку с файлами:

dict/
    meta.json
    gramtab-opencorpora-int.json
    gramtab-opencorpora-ext.json
    grammemes.json
    suffixes.json
    paradigms.array
    words.dawg
    prediction-suffixes-0.dawg
    prediction-suffixes-1.dawg
    prediction-suffixes-2.dawg

Файлы .json - обычные json-данные; .dawg - это двоичный формат C++ библиотеки dawgdic; paradigms.array - это массив чисел в двоичном виде.

Note

Если вы вдруг пишете морфологический анализатор не на питоне (и формат хранения данных устраивает), то вполне возможно, что будет проще использовать эти подготовленные словари, а не конвертировать словари из OpenCorpora еще раз; ничего специфичного для питона в сконвертированных словарях нет.

Характеристики

После применения описанных выше методов в pymorphy2 словарь со всеми сопутствующими данными занимает около 15Мб оперативной памяти; скорость разбора - от нескольких десятков тыс. слов/сек до > 100тыс. слов/сек (в зависимости от интерпретатора, настроек и выполняемой операции). Для сравнения:

  • в mystem словарь + код занимает около 20Мб оперативной памяти, скорость > 100тыс. слов/сек;
  • в lemmatizer из aot.ru словарь занимает 9Мб памяти (судя по данным отсюда), скорость > 200тыс слов/сек.;
  • в варианте морф. анализатора на конечных автоматах с питоновской оберткой к openfst (https://habrahabr.ru/post/109736/) сообщается, что словарь занимал 35/3 = 11Мб после сжатия, скорость порядка 2 тыс слов/сек без оптимизаций;
  • написанный на питоне вариант морф. анализатора на конечных автоматах (автор - Konstantin Selivanov) требовал порядка 300Мб памяти, скорость порядка 2 тыс. слов/сек;
  • в pymorphy 0.5.6 полностью загруженный в память словарь (этот вариант там не документирован) занимает порядка 300Мб, скорость порядка 1-2тыс слов/сек.
  • Про MAnalyzer v0.1 (основанный на алгоритмах из pymorphy1, но написанный на C++ и с использованием dawg) приводят сведения, что скорость разбора 900тыс слов/сек при потреблении памяти 40Мб;
  • в первом варианте формата словарей pymorphy2 (от которого я отказался) получалась скорость 20-60тыс слов/сек при 30M памяти или 2-5 тыс слов/сек при 5Мб памяти (предсказатель там не был реализован).

Цели обогнать C/C++ реализации у pymorphy2 нет; цель - скорость базового разбора должна быть достаточной для того, чтоб “продвинутые” операции работали быстро. 30 тыс. слов/сек или 300 тыс. слов/сек - это не очень важно для многих задач, т.к. накладные расходы на обработку и применение результатов разбора все равно, скорее всего, “съедят” эту разницу (особенно при использовании из питоньего кода).

Разбор несловарных слов

В тех случаях, когда слово не получается найти простым поиском по словарю (с учетом буквы “ё”), в дело вступают “предсказатели” - правила разбора несловарных слов.

Note

Алгоритмы предсказания в похожи на те, что описаны на aot.ru, и на те, что применяются в pymorphy1, но отличаются в деталях и содержат дополнительные эвристики.

Отсечение известных префиксов

Во многих языках (в том числе в русском и украинском) существует набор словообразовательных префиксов, которые можно приписать к слову, и которые при этом не меняют то, как слово разбирается и склоняется.

В pymorphy2 для каждого поддерживаемого языка хранится небольшой список таких префиксов (например, “не”, “анти”, “псевдо”, “супер”, “дву” и т.д. для русского). Если слово начинается с одного из таких префиксов, то pymorphy2 отсекает префикс, разбирает то, что осталось, а затем приписывает префикс обратно.

Отсечение неизвестных префиксов

Если 2 слова отличаются только тем, что к одному из них что-то приписано спереди, то, скорее всего, и склоняться они будут одинаково. Поэтому если разбираемое слово можно представить как ПРЕФИКС + какое-то другое слово из словаря, то pymorphy2 считает, что слово разбирается так же, как и это другое слово.

При этом должны выполняться несколько дополнительных условий:

  • длина словарного слова должна быть не меньше 3;
  • длина префикса не должна быть больше 5;
  • словарное слово - это существительное, прилагательное, глагол, причастие или деепричастие.

Алгоритм такой: сначала pymorphy2 пробует считать первую букву префиксом, потом первые две буквы, потом первые 3 буквы и т.д. до 5, и смотрит, нет ли остатка в словаре.

Предсказание по концу слова

В подходах с отсечением префиксов есть два принципиальных ограничения:

  • разбор не должен зависеть от префикса (что неверно для словоизменительных префиксов “по” и “наи”, которые образуют формы прилагательных);
  • морфологический анализатор должен уметь разбирать правую часть слова (путем поиска по словарю или еще как-то) - правая часть слова должна иметь какой-то смысл сама по себе.

Разбор многих слов нельзя предсказать, отсекая префикс и разбирая остаток как словарное слово. Например, хотелось бы, чтоб если в словаре было слово “кошка”, но не было “мошка” и “ошка”, на основе словарного слова “кошка” анализатор смог бы предположить, как склоняется “мошка” (т.к. они заканчиваются одинаково).

Для того, чтоб предсказывать формы слов по тому, как слова заканчиваются, при конвертации словарей pymorphy2 собирает статистику по окончаниям: для каждого возможного окончания слова (от 1 до 5 букв) сохраняются все возможные разборы. Другими словами, каждому возможному 1..5-буквенному окончанию сопоставляется массив с информацией о возможных вариантах разбора (частота, номер парадигмы, номер формы в парадигме).

Если для каждого “окончания” хранить все возможные варианты разбора, то получится заведомо много лишних (очень маловероятных) правил. Поэтому полученные разборы “очищаются”:

  • pymorphy2 сохраняет только самый частотный разбор для каждой части речи;
  • разборы, принадлежащие “непродуктивным” парадигмам, удаляются (непродуктивными сейчас считаются парадигмы, которым соответствует менее min_paradigm_popularity=3 лексем в словаре);
  • редкие окончания удаляются (те, которые встретились только min_ending_freq=1 раз);
  • не все части речи продуктивные: например, нельзя приписать что-то к предлогу, чтоб получить другой предлог; все предлоги есть в словаре, и предсказывать незнакомые слова как предлоги неправильно - такие варианты предсказания отбрасываются предсказателем.

Результат кодируется в DAFSA. Схема хранения похожа на ту, что в основном словаре (см. раздел Упаковка слов), только

  • вместо самих слов хранятся все их возможные окончания;
  • к номеру парадигмы и индексу формы в парадигме добавляется еще “продуктивность” данного правила - количество слов в словаре, которые имеют данное окончание и разбираются данным образом.
<конец слова> <разделитель> <продуктивность> <номер парадигмы> <номер формы в парадигме>

Разбор сводится к поиску наиболее длинной правой части разбираемого слова, которая есть в DAFSA с окончаниями.

Кроме того, для каждого словоизменительного префикса (ПО, НАИ) точно так же строится еще по одному DAFSA; если слово начинается с одного из этих префиксов, то анализатор добавляет к результату варианты предсказания, полученные поиском по соответствующему DAFSA.

Note

Термин “окончание” тут употребляется в смысле “правая часть слова определенной длины”; он не имеет отношения к “школьному” определению; кроме того, тут он не имеет отношения к “окончаниям” в парадигмах.

Наречия на по-

Несловарное слова предсказываются как наречия, если верно следующее:

  • слово начинается на “по-“;
  • оно длиннее 5 символов;
  • часть слова без “по-” может быть разобрана как прилагательное единственного числа дательного падежа.

Примеры: по-северному, по-хорошему.

Частица, отделенная дефисом

Иногда удобно слова, к которым через дефис приписана частица, разбирать как единый токен. Поэтому pymorphy2 умеет разбирать токены вроде “смотри-ка” или “посмотрел-таки”.

Составные слова, записанные через дефис

pymorphy2 поддерживает составные слова из двух частей, разделенных дефисом. Для таких слов pymorphy2 сначала разбирает обе части по отдельности (они могут быть несловарными словами).

В настоящий момент поддерживается 2 способа образования таких слов:

  1. Левая часть - неизменяемая приставка/основа (например, “интернет-магазин”, “воздушно-капельный”). В этом случае форма слова определяется второй частью. Этот случай добавляется в возможные варианты разбора всегда.
  2. Равноправные части, склоняемые вместе (например, “человек-паук”). Этот случай добавляется в возможные варианты разбора только тогда, когда обе части имеют совместимую форму (есть вариант разбора первой части, который не противоречит какому-то варианту разбора второй).

Note

Если слово содержит более одного дефиса (образовано более чем из двух частей), это правило не применяется.

Инициалы

Однобуквенные токены в верхнем регистре pymorphy2 предсказывает как инициалы: для них возвращаются варианты разбора “имя” и “отчество”, по всем родам, падежам и числам.

Сортировка результатов разбора

При предсказании по концу слова результаты сортируются по “продуктивности” вариантов разбора: наиболее продуктивные варианты будут первыми.

Другими словами, варианты разбора (= номера парадигм) упорядочены по частоте, с которой эти номера парадигм соответствуют данному окончанию для данной части речи - без учета частотности по корпусу.

Экспериментального подтверждения правильности этого подхода нет, но “интуиция” тут такая:

  1. нам не важно, какие слова в корпусе встречаются часто, т.к. предсказатель работает для редких слов, и редкие слова он должен предсказывать как редкие, а не как распространенные;
  2. для “длинного хвоста” частотности в корпусе конкретные цифры имеют не очень много значения, т.к. флуктуации очень большие, “эффект хоббита” и т.д.
  3. С другой стороны, важно, какие парадигмы в русском языке более продуктивные, какие порождают больше слов.

Поэтому используется частотность по парадигмам, полученная исключительно из словаря.

Note

В настоящий момент результаты сортируются только при предсказании по концу слова. Разборы для словарных слов и разборы, предсказанные путем отсечения префикса, специальным образом сейчас не сортируются.

Буква Ё

Если не ударяться в крайности, то можно считать, что в русском языке употребление буквы “ё” допустимо, но не обязательно. Это означает, что как в исходном тексте, так и в словарях она иногда может быть, а иногда ее может не быть.

В pymorphy2 считается, что:

  • в словарях употребление буквы “ё” обязательно; “е” вместо “ё” (как и “ё” вместо “е”) - это ошибка в словаре. Иными словами, “е” и “ё” в словарях - две совсем разные буквы.

  • В текстах/словах, которые подаются на вход морфологического анализатора, употребление буквы “ё” необязательно. Например, слово “озера” должно быть разобрано и как “(нет) озера”, и как “(глубокие) озёра”.

    Note

    При этом входное слово “озёра” будет однозначно разобрано как “(глубокие) озёра”.

Детали реализации

“Наивный” подход - это генерация все вариантов возможных замен “е” на “ё” во входном слове и проверка всех вариантов по словарю. В русском языке “е” - очень распространенная буква, и много слов, где “е” встречается несколько раз. Например, для слова с 3 буквами “е” нужно сгенерировать еще 7 вариантов слова - вместо 1 проверки по словарю нужно было бы выполнить 8 (+ время на генерацию вариантов слов).

При разборе pymorphy2 использует другой подход - все слова хранятся в графе, и при обходе графа кроме направлений “е” каждый раз еще пробуется направление “ё”. При этом в исходном коде pymorphy2 этого обхода графа в явном виде нет, т.к. библиотеки DAWG и DAWG-Python сами умеет производить “поиск с возможными заменами”.

Note

По оценкам, полученным в начале разработки (которые, соответственно, могут быть неверными для текущей версии), поддержка буквы “ё” в pymorphy2 замедляла разбор на 10-40% (в зависимости от интерпретатора).

Разное

История изменений

0.9.1 (2020-09-27)

Исправлено обнаружение словарей в случае, когда pymorphy2 установлен после запуска процесса. Это типичная ситуация в Jupyter Notebook или Google Colab - в начале блокнота установить зависимости (!pip install pymorphy2); pymorphy2==0.9 не работал в этом случае без перезапуска блокнота.

0.9 (2020-09-20)

Новые возможности:

  • Добавлена экспериментальная поддержка украинского языка. См. документацию.
  • Улучшена утилита командной строки. См. pymorphy –help.
  • Добавлена поддержка Python 3.7 и 3.8.

Обратно-несовместимые изменения:

  • Внутренняя организация кода сильно поменялась
  • Python 2.6 и 3.2 - 3.4 больше не поддерживаются. Python 2.7 пока поддерживается, но поддержка Python 2.x будет убрана в pymorphy2 v1.0.

Исправления ошибок, небольшие улучшения:

  • Исправлена некорректная работа MorphAnalyzer в многопоточных программах
  • улучшено поведение метода .inflect
  • исправлена ошибка, вызванная некорректным кешированием нормальных форм
  • Команды для скачивания и сборки словарей перенесены в пакет pymorphy2-dicts
  • Ускорение сборки словарей
  • Небольшое ускорение токенизатора
  • улучшения в тестах и документации
0.8 (2014-06-06)
  • pymorphy2 теперь использует setuptools;
  • на pypi доступен пакет в формате wheel;
  • зависимости устанавливаются автоматически;
  • можно установить “быструю” версию через pip install pymorphy2[fast];
  • копия docopt больше не распространяется вместе с pymorphy2; пакет pymorphy2.vendor больше не доступен.

В этом релизе изменен способ установки pymorphy2; никаких изменений в разборе по сравнению с 0.7 нет.

0.7 (2014-05-26)
  • Методы parse() и tag() теперь всегда возвращают хотя бы один вариант разбора: если разбор не удался, то вместо пустого списка теперь возвращается список с одним элементом UNKN;
  • функция pymorphy2.shapes.restore_word_case() переименована в pymorphy2.shapes.restore_capitalization();
  • проверена совместимость с Python 3.4;
  • в список для замен падежей OpencorporaTag.RARE_CASES добавлены граммемы gen1, acc1 и loc1 - они не используются в pymorphy2, но могут встречаться в выгрузке корпуса OpenCorpora;
  • убран DeprecationWarning при использовании psutil < 2.x;
  • небольшие улучшения в документации.
0.6.1 (2014-04-23)
  • Для инициалов добавлена граммема Init.
0.6 (2014-04-22)
  • Заглавные буквы предсказываются как инициалы;
  • улучшен внутренний API для предсказателей - флаг terminal больше не нужен;
  • улучшения в тестах.

Если вы использовали параметр units в конструкторе MorphAnalyzer, то вам нужно будет обновить код, т.к. вместо флага terminal теперь предсказатели нужно группировать в list-ы в параметре units.

0.5 (2013-11-05)
  • Методы MorphAnalyzer.cyr2lat, MorphAnalyzer.lat2cyr и атрибут OpencorporaTag.cyr_repr для преобразования между тегами/граммемами, записанными латиницей и кириллицей;
  • тег для целых чисел теперь NUMB,intg; для вещественных - NUMB,real (раньше для всех был просто NUMB);
  • KnownSuffixAnalyzer теперь не вызывается для слов короче 4 символов.
0.4 (2013-10-19)
  • Parse.estimate переименован в score и содержит теперь оценку P(tag|word) на основе данных из OpenCorpora;
  • по умолчанию результаты разбора сортируются по score.

То, что результатам сопоставляется оценка P(tag|word), может в некоторых случаях снизить скорость разбора раза в 1.5 - 2. Если эти оценки не нужны, создайте экземпляр MorphAnalyzer с параметром probability_estimator_cls=None.

Для обновления требуется обновить pymorphy2-dicts до версии >= 2.4, а также библиотеки DAWG или DAWG-Python до версиий >= 0.7.

0.3.5 (2013-06-30)
  • Препроцессинг словаря: loc1/gen1/acc1 заменяются на loct/gent/accs; варианты написания тегов унифицируются (чтоб их было меньше);
  • исправлено согласование слов с числительными;
  • при склонении слов в loc2/gen2/acc2/voct слово ставится в loct/gent/accs/nomn, если вариантов с loc2/gen2/acc2/voct не найдено.

Для полноценного обновления лучше обновить pymorphy2-dicts до версии >= 2.2.

0.3.4 (2013-04-29)
  • Добавлен метод Parse.make_agree_with_number для согласования слов с числительными;
  • небольшие улучшения в документации.
0.3.3 (2013-04-12)
  • Исправлен тег, который выдает RomanNumberAnalyzer (теперь это ROMN, как в OpenCorpora);
  • добавлена функция pymorphy2.tokenizers.simple_word_tokenize, которая разбивает текст по пробелам и пунктуации (но не дефису);
  • исправлена ошибка с разбором слов вроде “ретро-fm” (pymorphy2 раньше падал с исключением).
0.3.2 (2013-04-03)
  • добавлен RomanNumberAnalyzer для разбора римских чисел;
  • MorphAnalyzer и OpencorporaTag теперь можно сериализовывать с помощью pickle;
  • улучшены тесты;
  • при компиляции словаря версия xml печатается раньше.
0.3.1 (2013-03-12)
  • Поправлен метод MorphAnalyzer.word_is_known, который раньше учитывал регистр слова (что неправильно);
  • исправлена ошибка в разборе слов с дефисом (тех, у которых лишний дефис справа или слева).
0.3 (2013-03-11)
  • Рефакторинг: теперь при необходимости можно дописывать свои “шаги” морфологического анализа (“предсказатели”) и комбинировать их с существующими (документация пока не готова, и API может поменяться);
  • на вход больше не обязательно подавать слова в нижнем регистре (но на выходе при этом регистр сохраняться не обязан - используйте функцию pymorphy2.shapes.restore_word_case, если требуется восстановить регистр полученных слов);
  • улучшено предсказание неизвестных слов по словообразовательным префиксам (учитывается больше таких префиксов);
  • реализован разбор (и склонение) слов с дефисами;
  • результаты разбора теперь включают в себя полную информацию о том, как слово разбиралось; наличие para_id и idx при этом больше не обязательно;
  • анализатор теперь отмечает пунктуацию тегом PNCT, числа - тегом NUMB, слова, записанные латиницей - тегом LATN;
  • улучшено предсказание по неизвестному префиксу (добавлено ограничение по граммеме Apro);
  • улучшения в тестах и бенчмарках;
  • удален атрибут morph.dict_meta (используйте morph.dictionary.meta);
  • удален (возможно, временно) метод MorphAnalyzer.inflect (используйте метод inflect у результата разбора);
  • удален метод MorphAnalyzer.decline (используйте parse.lexeme);
  • удалено свойство Parse.paradigm.

В результате этих изменений улучшилось качество разбора, качество склонения и возможности по расширению библиотеки (втч для настройки под конкретную задачу), но скорость работы “из коробки” по сравнению с 0.2 снизилась примерно на треть.

0.2 (2013-02-18)
  • Улучшения в предсказателе: учет словоизменительных префиксов;
  • улучшения в предсказателе: равноценные варианты разбора не отбрасываются;
  • изменена схема проверки совместимости словарей;
  • изменен формат словарей (нужно обновить pymorphy2-dicts до 2.0);
  • добавлено свойство Parse.paradigm.
0.1 (2013-02-14)

Первый альфа-релиз. Релизована основа: эффективный разбор и склонение, обновление словарей, полная поддержка буквы ё.

Многие вещи, которые были доступны в pymorphy, пока не работают (разбор слов с дефисом, разбор фамилий, поддержка шаблонов django, утилиты из contrib).

Кроме того, API пока не зафиксирован и может меняться в последующих релизах.

Authors and Contributors

  • Mikhail Korobov;
  • @radixvinni;
  • @ivirabyan;
  • @anti-social;
  • @insolor.

If you contributed to pymorphy2, please add yourself to this list (or update your contact information).

Many people contributed to pymorphy2 predecessor, pymorphy; they are listed here: https://github.com/kmike/pymorphy/blob/master/AUTHORS.rst

API Reference (auto-generated)

Morphological Analyzer
class pymorphy2.analyzer.MorphAnalyzer(path=None, lang=None, result_type=<class 'pymorphy2.analyzer.Parse'>, units=None, probability_estimator_cls=<object object>, char_substitutes=<object object>)[source]

Morphological analyzer for Russian language.

For a given word it can find all possible inflectional paradigms and thus compute all possible tags and normal forms.

Analyzer uses morphological word features and a lexicon (dictionary compiled from XML available at OpenCorpora.org); for unknown words heuristic algorithm is used.

Create a MorphAnalyzer object:

>>> import pymorphy2
>>> morph = pymorphy2.MorphAnalyzer()

MorphAnalyzer uses dictionaries from pymorphy2-dicts package (which can be installed via pip install pymorphy2-dicts).

Alternatively (e.g. if you have your own precompiled dictionaries), either create PYMORPHY2_DICT_PATH environment variable with a path to dictionaries, or pass path argument to pymorphy2.MorphAnalyzer constructor:

>>> morph = pymorphy2.MorphAnalyzer(path='/path/to/dictionaries') 

By default, methods of this class return parsing results as namedtuples Parse. This has performance implications under CPython, so if you need maximum speed then pass result_type=None to make analyzer return plain unwrapped tuples:

>>> morph = pymorphy2.MorphAnalyzer(result_type=None)
DEFAULT_SUBSTITUTES = {'е': 'ё'}
DEFAULT_UNITS = [[DictionaryAnalyzer(), AbbreviatedFirstNameAnalyzer(letters='АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЭЮЯ', score=0.1, tag_pattern='NOUN,anim,%(gender)s,Sgtm,Name,Fixd,Abbr,Init sing,%(case)s'), AbbreviatedPatronymicAnalyzer(letters='АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЭЮЯ', score=0.1, tag_pattern='NOUN,anim,%(gender)s,Sgtm,Patr,Fixd,Abbr,Init sing,%(case)s')], NumberAnalyzer(score=0.9), PunctuationAnalyzer(score=0.9), [RomanNumberAnalyzer(score=0.9), LatinAnalyzer(score=0.9)], HyphenSeparatedParticleAnalyzer(particles_after_hyphen=['-то', '-ка', '-таки', '-де', '-тко', '-тка', '-с', '-ста'], score_multiplier=0.9), HyphenAdverbAnalyzer(score_multiplier=0.7), HyphenatedWordsAnalyzer(score_multiplier=0.75, skip_prefixes=<...>), KnownPrefixAnalyzer(known_prefixes=<...>, min_remainder_length=3, score_multiplier=0.75), [UnknownPrefixAnalyzer(score_multiplier=0.5), KnownSuffixAnalyzer(min_word_length=4, score_multiplier=0.5)], UnknAnalyzer()]
DICT_PATH_ENV_VARIABLE = 'PYMORPHY2_DICT_PATH'
TagClass
Return type:pymorphy2.tagset.OpencorporaTag
char_substitutes = None
classmethod choose_dictionary_path(path=None, lang=None)[source]
classmethod choose_language(dictionary, lang)[source]
cyr2lat(tag_or_grammeme)[source]

Return Latin representation for tag_or_grammeme string

get_lexeme(form)[source]

Return the lexeme this parse belongs to.

iter_known_word_parses(prefix='')[source]

Return an iterator over parses of dictionary words that starts with a given prefix (default empty prefix means “all words”).

lat2cyr(tag_or_grammeme)[source]

Return Cyrillic representation for tag_or_grammeme string

normal_forms(word)[source]

Return a list of word normal forms.

parse(word)[source]

Analyze the word and return a list of pymorphy2.analyzer.Parse namedtuples:

Parse(word, tag, normal_form, para_id, idx, _score)

(or plain tuples if result_type=None was used in constructor).

tag(word)[source]
word_is_known(word, strict=False)[source]

Check if a word is in the dictionary.

By default, some fuzziness is allowed, depending on a dictionary - e.g. for Russian ё letters replaced with е are handled. Pass strict=True to make matching strict (e.g. if it is guaranteed the word has correct е/ё or г/ґ letters).

Note

Dictionary words are not always correct words; the dictionary also contains incorrect forms which are commonly used. So for spellchecking tasks this method should be used with extra care.

class pymorphy2.analyzer.Parse[source]

Parse result wrapper.

inflect(required_grammemes)[source]
is_known

True if this form is a known dictionary form.

lexeme

A lexeme this form belongs to.

make_agree_with_number(num)[source]

Inflect the word so that it agrees with num

normalized

A Parse instance for self.normal_form.

class pymorphy2.analyzer.ProbabilityEstimator(dict_path)[source]
apply_to_parses(word, word_lower, parses)[source]
apply_to_tags(word, word_lower, tags)[source]
pymorphy2.analyzer.lang_dict_path(lang)[source]

Return language-specific dictionary path

Analyzer units
Dictionary analyzer unit
class pymorphy2.units.by_lookup.DictionaryAnalyzer[source]

Analyzer unit that analyzes word using dictionary.

get_lexeme(form)[source]

Return a lexeme (given a parsed word).

parse(word, word_lower, seen_parses)[source]

Parse a word using this dictionary.

tag(word, word_lower, seen_tags)[source]

Tag a word using this dictionary.

Analogy analyzer units

This module provides analyzer units that analyzes unknown words by looking at how similar known words are analyzed.

class pymorphy2.units.by_analogy.KnownPrefixAnalyzer(known_prefixes, score_multiplier=0.75, min_remainder_length=3)[source]

Parse the word by checking if it starts with a known prefix and parsing the remainder.

Example: псевдокошка -> (псевдо) + кошка.

class pymorphy2.units.by_analogy.KnownSuffixAnalyzer(score_multiplier=0.5, min_word_length=4)[source]

Parse the word by checking how the words with similar suffixes are parsed.

Example: бутявкать -> …вкать

class FakeDictionary[source]

This is just a DictionaryAnalyzer with different __repr__

class pymorphy2.units.by_analogy.UnknownPrefixAnalyzer(score_multiplier=0.5)[source]

Parse the word by parsing only the word suffix (with restrictions on prefix & suffix lengths).

Example: байткод -> (байт) + код

Analyzer units for unknown words with hyphens
class pymorphy2.units.by_hyphen.HyphenAdverbAnalyzer(score_multiplier=0.7)[source]

Detect adverbs that starts with “по-“.

Example: по-западному

class pymorphy2.units.by_hyphen.HyphenSeparatedParticleAnalyzer(particles_after_hyphen, score_multiplier=0.9)[source]

Parse the word by analyzing it without a particle after a hyphen.

Example: смотри-ка -> смотри + “-ка”.

Note

This analyzer doesn’t remove particles from the result so for normalization you may need to handle particles at tokenization level.

lexemizer(form, this_method)[source]

A coroutine for preparing lexemes

normalizer(form, this_method)[source]

A coroutine for normalization

class pymorphy2.units.by_hyphen.HyphenatedWordsAnalyzer(skip_prefixes, score_multiplier=0.75)[source]

Parse the word by parsing its hyphen-separated parts.

Examples:

  • интернет-магазин -> “интернет-” + магазин
  • человек-гора -> человек + гора
Analyzer units that analyzes non-word tokes
class pymorphy2.units.by_shape.LatinAnalyzer(score=0.9)[source]

This analyzer marks latin words with “LATN” tag. Example: “pdf” -> LATN

class pymorphy2.units.by_shape.NumberAnalyzer(score=0.9)[source]

This analyzer marks integer numbers with “NUMB,int” or “NUMB,real” tags. Example: “12” -> NUMB,int; “12.4” -> NUMB,real

Note

Don’t confuse it with “NUMR”: “тридцать” -> NUMR

class pymorphy2.units.by_shape.PunctuationAnalyzer(score=0.9)[source]

This analyzer tags punctuation marks as “PNCT”. Example: “,” -> PNCT

class pymorphy2.units.by_shape.RomanNumberAnalyzer(score=0.9)[source]
Tagset

Utils for working with grammatical tags.

class pymorphy2.tagset.OpencorporaTag(tag)[source]

Wrapper class for OpenCorpora.org tags.

Warning

In order to work properly, the class has to be globally initialized with actual grammemes (using _init_grammemes method).

Pymorphy2 initializes it when loading a dictionary; it may be not a good idea to use this class directly. If possible, use morph_analyzer.TagClass instead.

Example:

>>> from pymorphy2 import MorphAnalyzer
>>> morph = MorphAnalyzer()
>>> Tag = morph.TagClass  # get an initialzed Tag class
>>> tag = Tag('VERB,perf,tran plur,impr,excl')
>>> tag
OpencorporaTag('VERB,perf,tran plur,impr,excl')

Tag instances have attributes for accessing grammemes:

>>> print(tag.POS)
VERB
>>> print(tag.number)
plur
>>> print(tag.case)
None

Available attributes are: POS, animacy, aspect, case, gender, involvement, mood, number, person, tense, transitivity and voice.

You may check if a grammeme is in tag or if all grammemes from a given set are in tag:

>>> 'perf' in tag
True
>>> 'nomn' in tag
False
>>> 'Geox' in tag
False
>>> set(['VERB', 'perf']) in tag
True
>>> set(['VERB', 'perf', 'sing']) in tag
False

In order to fight typos, for unknown grammemes an exception is raised:

>>> 'foobar' in tag
Traceback (most recent call last):
...
ValueError: Grammeme is unknown: foobar
>>> set(['NOUN', 'foo', 'bar']) in tag
Traceback (most recent call last):
...
ValueError: Grammemes are unknown: {'bar', 'foo'}

This also works for attributes:

>>> tag.POS == 'plur'
Traceback (most recent call last):
...
ValueError: 'plur' is not a valid grammeme for this attribute. Valid grammemes: ...
classmethod cyr2lat(tag_or_grammeme)[source]

Return Latin representation for tag_or_grammeme string

cyr_repr

Cyrillic representation of this tag

classmethod fix_rare_cases(grammemes)[source]

Replace rare cases (loc2/voct/…) with common ones (loct/nomn/…).

grammemes

A frozenset with grammemes for this tag.

grammemes_cyr

A frozenset with Cyrillic grammemes for this tag.

classmethod lat2cyr(tag_or_grammeme)[source]

Return Cyrillic representation for tag_or_grammeme string

updated_grammemes(required)[source]

Return a new set of grammemes with required grammemes added and incompatible grammemes removed.

Command-Line Interface

Usage:

pymorphy parse [options] [<input>]
pymorphy dict meta [--lang <lang> | --dict <path>]
pymorphy dict mem_usage [--lang <lang> | --dict <path>] [--verbose]
pymorphy -h | --help
pymorphy --version

Options:

-l --lemmatize      Include normal forms (lemmas)
-s --score          Include non-contextual P(tag|word) scores
-t --tag            Include tags
--thresh <NUM>      Drop all results with estimated P(tag|word) less
                    than a threshold [default: 0.0]
--tokenized         Assume that input text is already tokenized:
                    one token per line.
-c --cache <SIZE>   Cache size, in entries. Set it to 0 to disable
                    cache; use 'unlim' value for unlimited cache
                    size [default: 20000]
--lang <lang>       Language to use. Allowed values: ru, uk [default: ru]
--dict <path>       Dictionary folder path
-v --verbose        Be more verbose
-h --help           Show this help
Utilities for OpenCorpora Dictionaries
class pymorphy2.opencorpora_dict.wrapper.Dictionary(path)[source]

OpenCorpora dictionary wrapper class.

build_normal_form(para_id, idx, fixed_word)[source]

Build a normal form.

build_paradigm_info(para_id)[source]

Return a list of

(prefix, tag, suffix)

tuples representing the paradigm.

build_stem(paradigm, idx, fixed_word)[source]

Return word stem (given a word, paradigm and the word index).

build_tag_info(para_id, idx)[source]

Return tag as a string.

iter_known_words(prefix='')[source]

Return an iterator over (word, tag, normal_form, para_id, idx) tuples with dictionary words that starts with a given prefix (default empty prefix means “all words”).

word_is_known(word, substitutes_compiled=None)[source]

Check if a word is in the dictionary.

To allow some fuzzyness pass substitutes_compiled argument; it should be a result of DAWG.compile_replaces(). This way you can e.g. handle ё letters replaced with е in the input words.

Note

Dictionary words are not always correct words; the dictionary also contains incorrect forms which are commonly used. So for spellchecking tasks this method should be used with extra care.

Various Utilities
pymorphy2.tokenizers.simple_word_tokenize(text, _split=<built-in method split of re.Pattern object>)[source]

Split text into tokens. Don’t split by a hyphen. Preserve punctuation, but not whitespaces.

pymorphy2.shapes.is_latin(token)[source]

Return True if all token letters are latin and there is at least one latin letter in the token:

>>> is_latin('foo')
True
>>> is_latin('123-FOO')
True
>>> is_latin('123')
False
>>> is_latin(':)')
False
>>> is_latin('')
False
pymorphy2.shapes.is_punctuation(token)[source]

Return True if a word contains only spaces and punctuation marks and there is at least one punctuation mark:

>>> is_punctuation(', ')
True
>>> is_punctuation('..!')
True
>>> is_punctuation('x')
False
>>> is_punctuation(' ')
False
>>> is_punctuation('')
False
pymorphy2.shapes.is_roman_number(token, _match=<built-in method match of re.Pattern object>)[source]

Return True if token looks like a Roman number:

>>> is_roman_number('II')
True
>>> is_roman_number('IX')
True
>>> is_roman_number('XIIIII')
False
>>> is_roman_number('')
False
pymorphy2.shapes.restore_capitalization(word, example)[source]

Make the capitalization of the word be the same as in example:

>>> restore_capitalization('bye', 'Hello')
'Bye'
>>> restore_capitalization('half-an-hour', 'Minute')
'Half-An-Hour'
>>> restore_capitalization('usa', 'IEEE')
'USA'
>>> restore_capitalization('pre-world', 'anti-World')
'pre-World'
>>> restore_capitalization('123-do', 'anti-IEEE')
'123-DO'
>>> restore_capitalization('123--do', 'anti--IEEE')
'123--DO'

In the alignment fails, the reminder is lower-cased:

>>> restore_capitalization('foo-BAR-BAZ', 'Baz-Baz')
'Foo-Bar-baz'
>>> restore_capitalization('foo', 'foo-bar')
'foo'
pymorphy2.shapes.restore_word_case(word, example)[source]

This function is renamed to restore_capitalization

pymorphy2.utils.combinations_of_all_lengths(it)[source]

Return an iterable with all possible combinations of items from it:

>>> for comb in combinations_of_all_lengths('ABC'):
...     print("".join(comb))
A
B
C
AB
AC
BC
ABC
pymorphy2.utils.get_mem_usage()[source]

Return memory usage of the current process, in bytes. Requires psutil Python package.

pymorphy2.utils.json_read(filename, **json_options)[source]

Read an object from a json file filename

pymorphy2.utils.json_write(filename, obj, **json_options)[source]

Create file filename with obj serialized to JSON

pymorphy2.utils.kwargs_repr(kwargs=None, dont_show_value=None)[source]
>>> kwargs_repr(dict(foo="123", a=5, x=8))
"a=5, foo='123', x=8"
>>> kwargs_repr(dict(foo="123", a=5, x=8), dont_show_value=['foo'])
'a=5, foo=<...>, x=8'
>>> kwargs_repr()
''
pymorphy2.utils.largest_elements(iterable, key, n=1)[source]

Return a list of large elements of the iterable (according to key function).

n is a number of top element values to consider; when n==1 (default) only largest elements are returned; when n==2 - elements with one of the top-2 values, etc.

>>> s = [-4, 3, 5, 7, 4, -7]
>>> largest_elements(s, abs)
[7, -7]
>>> largest_elements(s, abs, 2)
[5, 7, -7]
>>> largest_elements(s, abs, 3)
[-4, 5, 7, 4, -7]
pymorphy2.utils.longest_common_substring(data)[source]

Return a longest common substring of a list of strings:

>>> longest_common_substring(["apricot", "rice", "cricket"])
'ric'
>>> longest_common_substring(["apricot", "banana"])
'a'
>>> longest_common_substring(["foo", "bar", "baz"])
''
>>> longest_common_substring(["", "foo"])
''
>>> longest_common_substring(["apricot"])
'apricot'
>>> longest_common_substring([])
''

See http://stackoverflow.com/questions/2892931/.

pymorphy2.utils.with_progress(iterable, desc=None, total=None, leave=True)[source]

Return an iterator which prints the iteration progress using tqdm package. Return iterable intact if tqdm is not available.

pymorphy2.utils.word_splits(word, min_reminder=3, max_prefix_length=5)[source]

Return all splits of a word (taking in account min_reminder and max_prefix_length).

Первоначальный формат словарей (отброшенный)

Warning

Этот формат словарей в pymorphy2 не используется; описание - документация по менее удачной попытке организовать словари.

Первоначальная реализация доступна в одном из первых коммитов. Рассматривайте описанное ниже как бесполезный на практике “исторический” документ.

В публикации по mystem был описан способ упаковки словарей с использованием 2 trie для “стемов” и “окончаний”. В первом прототипе pymorphy2 был реализован схожий способ; впоследствии я заменил его на другой.

Этот первоначальный формат словарей в моей реализации обеспечивал скорость разбора порядка 20-60тыс слов/сек (без предсказателя) при потреблении памяти 30М (с использованием datrie), или порядка 2-5 тыс слов/сек при потреблении памяти 5M (с использованием marisa-trie).

Идея была в том, что слово просматривается с конца, при этом в первом trie ищутся возможные варианты разбора для данных окончаний; затем для всех найденных вариантов окончаний “начала” слов ищутся во втором trie; в результате возвращаются те варианты, где для “начала” и “конца” есть общие способы разбора.

Основной “затык” в производительности был в том, что для каждого слова требовалось искать общие для начала и конца номера парадигм. Это задача о пересечении 2 множеств, для которой мне не удалось найти красивого решения. Питоний set использовать было нельзя, т.к. это требовало очень много памяти.

Лучшее, что получалось - id парадигм хранились в 2 отсортированных массивах, а их пересечение находилось итерацией по более короткому массиву и “сужающимся” двоичным поиском по более длинному (параллельная итерация по обоим массивам на конкретных данных оказывалась всегда медленнее).

В pymorphy2 я в итоге решил использовать другой формат словарей, т.к.

  • другой формат проще;
  • алгоритмы работы получаются проще;
  • скорость разбора получается больше (порядка 100-200 тыс слов/сек без предсказателя) при меньшем потреблении памяти (порядка 15M).

Но при этом первоначальный формат потенциально позволяет тратить еще меньше памяти; некоторые способы ускорения работы с ним еще не были опробованы.

Уменьшение размера массивов, как мне кажется - наиболее перспективный тут способ ускорения. Для уменьшения размеров сравниваемых массивов требуется уменьшить количество парадигм (например, “вырожденных” с пустым стемом).

Выделение парадигм

Изначально в словаре из OpenCorpora нет понятия “парадигмы” слова. Парадигма - это таблица форм какого-либо слова, образец для склонения или спряжения.

В pymorphy2 выделенные явным образом парадигмы слов необходимы для того, чтоб склонять неизвестные слова - т.к. при этом нужны образцы для склонения.

Пример исходной леммы:

375080
ЧЕЛОВЕКОЛЮБИВ   100
ЧЕЛОВЕКОЛЮБИВА  102
ЧЕЛОВЕКОЛЮБИВО  105
ЧЕЛОВЕКОЛЮБИВЫ  110

Парадигма (пусть будет номер 12345):

""      100
"А"     102
"О"     105
"Ы"     110

Вся лемма при этом “сворачивается” в “стем” и номер парадигмы:

"ЧЕЛОВЕКОЛЮБИ" 12345

Note

Для одного “стема” может быть несколько допустимых парадигм.

Прилагательные на ПО-

В словарях у большинства сравнительных прилагательных есть формы на ПО-:

375081
ЧЕЛОВЕКОЛЮБИВЕЕ COMP,Qual V-ej
ПОЧЕЛОВЕКОЛЮБИВЕЕ       COMP,Qual Cmp2
ПОЧЕЛОВЕКОЛЮБИВЕЙ       COMP,Qual Cmp2,V-ej

Можно заметить, что в этом случае форма слова определяется не только тем, как слово заканчивается, но и тем, как слово начинается. Алгоритм с разбиением на “стем” и “окончание” приведет к тому, что все слово целиком будет считаться окончанием, а => каждое сравнительное прилагательное породит еще одну парадигму. Это увеличивает общее количество парадигм в несколько раз и делает невозможным склонение несловарных сравнительных прилагательных, поэтому в pymorphy2 парадигма определяется как “окончание”, “номер грам. информации” и “префикс”.

Пример парадигмы для “ЧЕЛОВЕКОЛЮБИВ”:

""      100     ""
"А"     102     ""
"О"     105     ""
"Ы"     110     ""

Пример парадигмы для “ЧЕЛОВЕКОЛЮБИВЕЕ”:

""      555     ""
""      556     "ПО"
""      557     "ПО"

Note

Сейчас обрабатывается единственный префикс - “ПО”. В словарях, похоже, нет других префиксов, присущих только отдельным формам слова в пределах одной леммы.

Упаковка “стемов”

“Стемы” - строки, основы лемм. Для их хранения используется структура данных trie (с использованием библиотеки datrie), что позволяет снизить потребление оперативной памяти (т.к. некоторые общие части слов не дублируются) и повысить скорость работы (т.к. в trie можно некоторые операции - например, поиск всех префиксов данной строки - можно выполнять значительно быстрее, чем в хэш-таблице).

Ключами в trie являются стемы (перевернутые), значениями - список с номерами допустимых парадигм.

Упаковка tuple/list/set

Для каждого стема требуется хранить множество id парадигм; обычно это множества из небольшого числа int-элементов. В питоне накладные расходы на set() довольно велики:

>>> import sys
>>> sys.getsizeof({})
280

Если для каждого стема создать даже по одному пустому экземпляру set, это уже займет порядка 80М памяти. Поэтому set() не используется; сначала я заменил их на tuple с отсортированными элементами. В таких tuple можно искать пересечения за O(N+M) через однопроходный алгоритм, аналогичный сортировке слиянием, или за O(N*log(M)) через двоичный поиск.

Но накладные расходы на создание сотен тысяч tuple с числами тоже велики, поэтому в pymorphy 2 они упаковываются в одномерный массив чисел (array.array).

Пусть у нас есть такая структура:

(
    (10, 20, 30),       # 0й элемент
    (20, 40),           # 1й элемент
)

Она упакуется в такой массив:

array.array([3, 10, 20, 30, 2, 20, 40])

Сначала указывается длина данных, затем идет сами данные, потом опять длина и опять данные, и т.д. Для доступа везде вместо старых индексов (0й элемент, 1й элемент) используются новые: 0й элемент, 4й элемент. Чтоб получить исходные цифры, нужно залезть в массив по новому индексу, получить длину N, и взять следующие N элементов.

Итоговый формат данных
Таблица с грам. информацией
['tag1', 'tag2', ...]

tag<N> - набор грам. тегов, например NOUN,anim,masc sing,nomn.

Этот массив занимает где-то 0.5M памяти.

Парадигмы
[
    (
        (suffix1, tag_index1, prefix1),
        (suffix2, tag_index2, prefix2),
        ...
    ),
    (
        ...
]

suffix<N> и prefix<N> - это строки с окончанием и префиксом (например, "ЫЙ" и ""); tag_index<N> - индекс в таблице с грам. информацией.

Парадигмы занимают примерно 7-8M памяти.

Note

tuple в парадигмах сейчас не упакованы в линейные структуры; упаковка должна уменьшить потребление памяти примерно на 3M.

Стемы

Стемы хранятся в 2 структурах:

  • array.array с упакованными множествами номеров возможных парадигм для данного стема:

    [length0, para_id0, para_id1, ..., length1, para_id0, para_id1, ...]
    
  • и trie с ключами-строками и значениями-индексами в массиве значений:

    datrie.BaseTrie(
        'stem1': index1,
        'stem2': index2,
        ...
    )
    
“Окончания”

Для каждого “окончания” хранится, в каких парадигмах на каких позициях оно встречается. Эта информация требуется для быстрого поиска нужного слова “с конца”. Для этого используются 3 структуры:

  • array.array с упакованными множествами номеров возможных парадигм для данного окончания:

    [length0, para_id0, para_id1, ..., length1, para_id0, para_id1, ...]
    

    В отличие от аналогичного множества для стемов, номера парадигм могут повторяться в пределах окончания.

  • array.array с упакованными множествами индексов в пределах парадигмы:

    [length0, index0, index1, ..., length1, index0, index1, ...]
    

    Этот массив работает “вместе” с предыдущим, каждому элементу отсюда соответствует элемент оттуда - совместно они предоставляют информацию о возможных номерах форм в парадигме для всех окончаний.

  • trie с ключами-строками и значениями-индексами:

    datrie.BaseTrie(
        'suff1': index1,
        'suff2': index2,
        ...
    )
    

    По индексу index<N> можно из предыдущих двух массивов получить наборы форм для данного окончания.

Note

Длины хранятся 2 раза. Может, это можно как-то улучшить?

Цитирование

Если вы использовали pymorphy2 в научных целях, то будет хорошо, если процитируете следующую статью:

Korobov M.: Morphological Analyzer and Generator for Russian and
Ukrainian Languages // Analysis of Images, Social Networks and Texts,
pp 320-332 (2015).

Это не обязательно, но автору будет приятно.

BibTeX:

@incollection{
   year={2015},
   isbn={978-3-319-26122-5},
   booktitle={Analysis of Images, Social Networks and Texts},
   volume={542},
   series={Communications in Computer and Information Science},
   editor={Khachay, Mikhail Yu. and Konstantinova, Natalia and Panchenko, Alexander and Ignatov, Dmitry I. and Labunets, Valeri G.},
   doi={10.1007/978-3-319-26123-2_31},
   title={Morphological Analyzer and Generator for Russian and Ukrainian Languages},
   url={http://dx.doi.org/10.1007/978-3-319-26123-2_31},
   publisher={Springer International Publishing},
   keywords={Morphological analyzer; Russian; Ukrainian; Morphological generator; Open source; OpenCorpora; LanguageTool; pymorphy2; pymorphy},
   author={Korobov, Mikhail},
   pages={320-332},
   language={English}
}

Препринт статьи доступен для скачивания на arxiv (pdf).

Терминология

лексема
Набор всех форм одного слова. Например, “ёж”, “ежи” и “ежам” входят в одну лексему. [1]
лемма
нормальная форма слова
Каноническая форма слова (например, форма единственного числа, именительного падежа для существительных). [2]
граммема

Значение какой-либо грамматической характеристики слова. Например, “множественное число” или “деепричастие”. Множество всех граммем, характеризующих данное слово, образует тег.

См. также: Обозначения для граммем (русский язык).

тег
Набор граммем, характеризующих данное слово. Например, для слова “ежам” тегом может быть 'NOUN,anim,masc plur,datv'.
парадигма
словоизменительная парадигма

Образец для склонения или спряжения; правила, согласно которым можно получить все формы слов в лексеме для данного стема.

В pymorphy2 для каждого слова в словаре указано, по каким парадигмам это слово могло быть образовано; pymorphy2 также умеет предсказывать парадигму для слов, отсутствующих в словаре.

стем
Неизменяемая часть слова.
[1]Часто не делается различия между леммой и лексемой, или термин “лемма” употребляется в значении “набор форм слова”. Но, похоже, данное выше определение лексемы все же более стандартное (см., например, см. википедию или Foundations of Statistical Natural Language Processing), поэтому в pymorphy2 набор всех форм слова называется именно лексемой.
[2]В pymorphy1 и в XML-словаре из OpenCorpora слово “лемма” употребляется в значении “лексема”. Чтобы не усугублять путаницу, в pymorphy2 вместо термина “лемма” употребляется термин “нормальная форма слова”, а термин “лемма” не используется совсем.

Исходный код - на github. Если заметили ошибку, то пишите в баг-трекер. Для обсуждения есть гугл-группа; если есть какие-то вопросы - пишите туда.