Трендовые github проекты в нашем телеграм канале. Подпишись → 621 тысяча слов в секунду: C++-морфология для русского NLP-пайплайна
В Python-проектах, которые работают с русским текстом, морфологию почти всегда отдают pymorphy2 или pymorphy3. Порог входа низкий, ставится одной командой, словарь OpenCorpora покрывает большинство задач. Проблемы начинаются на объёме: лемматизация вызывается на каждое слово, а слово в плоском тексте встречается примерно раз в 6–7 байт. Это около 150 000 вызовов анализатора на мегабайт текста, и в поисковом индексаторе или ML-препроцессинге этот этап часто оказывается самым медленным во всём пайплайне.
pyrusmorph — Python-расширение поверх libmorph/rus, морфологической библиотеки на C++. Её история начинается в конце 1990-х: она строилась на словаре А. А. Зализняка для проверки орфографии в пакете «Пропись» (интеграция с Microsoft Word и другими офисными пакетами), затем 30 лет развивалась внутри поисковиков Апорт, Рамблер и украинской Меты. За это время словарь вычитывали и пополняли современной лексикой.
Откуда берётся медлительность pymorphy
Словарь pymorphy3 хранится как DAWG — направленный ациклический граф слов. Сам по себе граф компактен, но каждый разбор в Python порождает пачку мелких объектов: список омонимов, теги, промежуточные строки. Каждый объект — аллокация в куче. На сотнях тысяч слов это даёт промахи по кэшам L1/L2 и лишнюю работу сборщику мусора. Ограничение сидит в рантайме Python, поэтому оптимизировать его внутри чистого Python-кода почти некуда.
Как организован libmorph/rus
У low-latency систем две главные заботы: словарь должен быть компактным, чтобы помещаться в кэш процессора, а на горячем пути не должно быть динамического выделения памяти. libmorph построен вокруг обоих требований.
| pymorphy3 | pyrusmorph (libmorph) | |
|---|---|---|
| Структура словаря | DAWG | сериализованные вырожденные радиксные деревья, один непрерывный блок ~2 МБ |
| Память при разборе | множество аллокаций в куче, включая результаты | только стек, фиксированные буферы, zero alloc |
| Словарь | OpenCorpora | расширенный словарь на базе Зализняка |
| Идентификация | строки и динамические представления | фиксированные числовые ID лексем и форм |
Разбор слова — это последовательный проход «снизу вверх» по таблицам радиксного дерева в статической неизменяемой памяти, с переходами по байтовым смещениям. Внутри данные хранятся в однобайтной кодировке Windows-1251. Все промежуточные структуры минимального размера и лежат на стеке.
Образ словаря отображается в память при загрузке библиотеки, поэтому пиковое потребление процесса держится на уровне его размера — около 2 МБ — и предсказуемо. Все внутренние данные используются только на чтение, так что в многопоточном окружении расход памяти не растёт с числом потоков.
Числовые ID вместо строк
Каждое слово в словнике пронумеровано. Результат лемматизации — целочисленный ID лексемы и ID формы, и по этой паре можно сразу синтезировать нужную словоформу, без обратного поиска по строке. Для поисковых систем это полезно отдельно: эти же ID можно класть прямо в инвертированный индекс. Вторичный словарь «нормальная форма → ID» внутри движка становится ненужным, и из системы уходит лишний уровень косвенности.
Бенчмарк
Стенд изолированный, тест однопоточный, на одном ядре одного процессора. Корпус — книга «Большая книга занимательных фактов в вопросах и ответах» А. П. Кондрашова: больше 4 МБ текста в UTF-8, около 2 млн символов, с латиницей, числами и обычной пунктуацией. Всего 405 000 токенов, из них 311 775 кириллических слов и 76 893 знака пунктуации.
Текст заранее разбивали на слова, а замеряли только последовательную лемматизацию кириллицы: приведение к словарной форме плюс извлечение морфологических признаков исходной формы.
| pymorphy3 | pyrusmorph | |
|---|---|---|
| Время, с | 13,7 | 0,5 |
| Скорость, слов/с | 22 800 | 621 000 |
| Опознано слов | 278 519 | 278 221 |
Тот же тест нативным C++-приложением на libmorph/rus выдаёт больше 1 300 000 слов в секунду. Получается, что обёртка pybind11 и пересечение границы Python/C++ сами по себе съедают примерно половину производительности. Даже с этой потерей выигрыш над pymorphy3 — 25–30 раз.
Чем отличаются результаты
pymorphy3 опознал примерно на 300 слов больше. Уникальных нераспознанных слов у pymorphy3 — 114, у pyrusmorph — 306, общих среди них 41 (из осмысленных — Ио, Поликсо, Ра, Фау).
Что пропускает pymorphy3: ИМТ, Антиопы, Гоморры, Денёв, Деций, Полидекта, Полифем, Транссиб.
Что пропускает pyrusmorph: Александро, Арахна, Бартоломеу, благоуспешно, Гавайи, Галилео, Гвидо, гинкго, ГОЭЛРО, гуппи, Дафна, коринфцы, Монгольфье, принародно, Сорренто, табуировано.
В обоих списках в основном аббревиатуры, античные имена, современные имена собственные и экзотическая география. У pyrusmorph заметнее дыры в иностранных именах и топонимах — если ваш корпус состоит из новостей или туристических текстов, это стоит проверить на своих данных до переезда.
Ложные срабатывания, то есть строки, которые анализатор принял за склоняемые формы:
- pymorphy3: АКТҐ (с украинской Ґ), апэреа, бактерио, бо, вениаминитя, взрыво, Дыхсу, Кпк, навсего, ЬйпйАее;
- pyrusmorph: Гус, ГэВ, кёй, коу, СсС, умм, Хоу, Ээт.
Часть ложных срабатываний pyrusmorph прописана в словаре явно: «Гус» там есть как имя футбольного тренера Гуса Хиддинка. pymorphy3 охотнее строит гипотезы по обобщённым моделям словоизменения и выдаёт больше вариантов разбора; libmorph сильнее опирается на словарь. Какое поведение лучше, зависит от задачи: для поиска с высокой полнотой гипотезы полезны, для точного разбора лишние варианты добавляют шум.
API
Пакет ставится из PyPI, лицензия MIT:
pip install pyrusmorph
Доступ к функциональности C++-ядра идёт через два класса: mlmaruWc — словарная лемматизация и синтез форм, mlfaruWc — эвристический (вероятностный) анализ слов, которых нет в словаре.
import pyrusmorph
morph = pyrusmorph.mlmaruWc()
fuzzy = pyrusmorph.mlfaruWc()
for lemma in morph.lemmatize('простой', 0): # омонимы с ID лексемы и граммемами
print(lemma.lexid, lemma.lemma)
for gram in lemma.grams:
# младшие 6 бит wdInfo — индекс части речи по Зализняку
print(' pos idx:', gram.wdInfo & 0x3f, 'form id:', gram.idForm)
print(morph.build_form(182067, 5)) # синтез формы по ID лексемы и ID формы
def on_match(lexeme_id, forms):
for f in forms:
print(lexeme_id, f.form, f.text)
morph.find_match('ко*ро*ва', on_match) # ? — один символ, * — любая последовательность
for stem in fuzzy.lemmatize('вундервафля', 0): # гипотезы для неизвестного слова
print(stem.fProb, stem.lemma[:stem.lStem], stem.lemma[stem.lStem:], stem.clsId)
print(fuzzy.build_form('командировоч', 14, 5)) # форма по основе и классу словоизменения
Часть речи возвращается числом, поэтому для вывода в человекочитаемом виде придётся завести таблицу отображения: индексы соответствуют пометам Зализняка (м, ж, св, нсв, п, числ и так далее).
Когда переходить
pyrusmorph имеет смысл пробовать, если лемматизация видна в профиле: индексация больших корпусов, препроцессинг для ML, потоковая обработка текстов, где сейчас приходится масштабировать воркеры из-за pymorphy. Дополнительные плюсы — память под словарь фиксирована на ~2 МБ и не растёт с числом потоков, а числовые ID лексем можно отдать прямо в поисковый индекс.
Перед заменой прогоните оба анализатора на выборке своих текстов и сравните списки нераспознанных слов: словари различаются, и на корпусе с большим количеством иностранных имён разница может оказаться заметнее, чем 300 слов на 311 тысяч из этого теста. Если и этого мало, остаётся вызывать libmorph напрямую из C++: там скорость примерно вдвое выше, чем через Python-обёртку.