Skip to content

Commit

Permalink
DOC simplify internals documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
kmike committed May 2, 2015
1 parent 50e1b11 commit 659a02a
Showing 1 changed file with 124 additions and 157 deletions.
281 changes: 124 additions & 157 deletions docs/internals/dict.rst
Expand Up @@ -8,13 +8,12 @@

.. _OpenCorpora: http://opencorpora.org

Упаковка словаря
Исходный словарь
----------------

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

1
ёж NOUN,anim,masc sing,nomn
ежа NOUN,anim,masc sing,gent
ежу NOUN,anim,masc sing,datv
Expand All @@ -28,179 +27,146 @@
ежами NOUN,anim,masc plur,ablt
ежах NOUN,anim,masc plur,loct

Сначала указывается номер лексемы, затем перечисляются формы слова и
соответствующая им грамматическая информация (:term:`тег`).
Первой формой в списке идет :term:`нормальная форма слова`.
В словаре около 400тыс. лексем и 5млн отдельных слов.
Лексема состоит из всех форм слова, причем для каждой формы указана
грамматическая информация (:term:`тег`). Первой формой в списке идет
:term:`нормальная форма слова`.

.. note::

С сайта OpenCorpora_ для скачивания доступны plaintext- и XML-версии
словаря. В pymorphy2 используется XML-версия, но (для простоты) тут
и далее в примерах показан plaintext-формат. Сами данные в XML-версии
те же.
Две основные операции, которые умеет делать морфологический
анализатор - разбор и склонение слов. Если у нас есть словарь с лексемами,
и мы хотим разобрать/просклонять словарное слово, то эти операции
очень простые:

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

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

Каждым :term:`тегом <тег>` (например, ``NOUN,anim,masc sing,nomn``)
обычно помечено более одного слова (часто - очень много слов).
Хранить строку целиком для всех 5млн слов накладно по 2 причинам:
* в словаре OpenCorpora для русского языка около 400тыс. лексем и
5млн отдельных слов; если все загрузить в питоний list,
то потратим примерно 2Гб оперативной памяти (в dict - еще больше);
* хотелось бы, чтоб операции по анализу и склонению слов осуществлялись
быстро - в том числе для слов, отсутствующих в словаре, и слов, записанных
каким-то "упрощенным" способом (например, с буквой е вместо ё).

- в питоне не гарантировано, что ``id(string1) == id(string2)``, если
``string1 == string2`` (хотя функция ``intern`` может помочь);
- строки нельзя хранить в `array.array`_, а у list накладные расходы выше,
т.к. он в питоне реализован как массив указателей на объекты, поэтому
в случае с тегами важно, чтоб каждому слову была сопоставлена цифра,
а не строка.
Чтобы сэкономить оперативную память и обеспечить быстрый анализ как
словарных, так и несловарных слов, pymorphy2:

.. _array.array: http://docs.python.org/3/library/array.html
* извлекает "парадигмы" из лексем;
* преобразует информацию в цифры, когда можно;
* кодирует слова в DAFSA_.

В pymorphy2 все возможные теги хранятся в массиве (в list); для каждого слова
указывается только номер тега.

Пример::

1
ёж 1
ежа 2
ежу 3
ежа 4
ежом 5
еже 6
ежи 7
ежей 8
ежам 9
ежей 10
ежами 11
ежах 12

набор тегов::

['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',
# ...
]
.. _DAFSA: http://en.wikipedia.org/wiki/Directed_acyclic_word_graph

.. _paradigms:

Парадигмы
---------

Изначально в словаре из OpenCorpora_ нет понятия :term:`парадигмы <парадигма>`
слова (парадигма - это образец для склонени или спряжения слов).
В pymorphy2 выделенные явным образом словоизменительные парадигмы необходимы
для того, чтоб склонять неизвестные слова (т.к. при этом нужны образцы
для склонения).

.. note::

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

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

тихий 100
тихого 102
тихому 105
...
тише 124
потише 148

У слов в этой лексеме есть неизменяемая часть (:term:`стем` "ти"),
изменяемое "окончание" и необязательный "префикс" ("по"). Выделив у
каждой формы "окончание" и "префикс", можно разделить лексему на
стем и таблицу для склонения::

стем: ти
таблица для склонения ("окончание", номер тега, "префикс"):

"хий" 100 ""
"хого" 102 ""
"хому" 105 ""
...
"ше" 124 ""
"ше" 125 "по"

Для многих :term:`лексем <лексема>` таблицы для склонения получаются
одинаковыми. В pymorphy2 выделенные таким образом таблицы для склонения
принимаются за парадигмы.

"Окончания" и "префиксы" в парадигмах повторяются, и хорошо
бы их не хранить по многу раз (а еще лучше - создавать
поменьше питоньих объектов для них), поэтому все возможные
"окончания" хранятся в отдельном массиве, а в парадигме указывается
только номер "окончания"; с "префиксами" - то же самое.

В итоге получается примерно так::

55 100 0
56 102 0
57 105 0
...
73 124 0
73 125 1

.. note::

Сейчас все возможные окончания парадигм хранятся в list;
возможно, было бы более эффективно хранить их в DAWG или Trie и
использовать perfect hash для сопоставления индекс <-> слово,
но сейчас это не реализовано.

Линеаризация парадигм
---------------------

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

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

Поэтому каждая парадигма упаковывается в одномерный массив: сначала идут
все номера окончаний, потом все номера тегов, потом все номера
Извлечение парадигм
-------------------

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

СЛОВО ТЕГ
хомяковый 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

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

При компиляции словаря 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`_):
сначала идут все номера хвостов, потом все номера тегов, потом все номера
префиксов::

55 56 57 ... 73 73 | 100 102 105 ... 124 125 | 0 0 0 ... 0 1
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``.

Хранить числа в питоньем list накладно, т.к. в Python числа типа int - это
тоже объекты и требуют памяти::
.. _array.array: http://docs.python.org/3/library/array.html

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

>>> import sys
>>> sys.getsizeof(1001)
24
Тройки "номер хвоста, номер грамматической информации, номер префикса"
в tuple хранить расточительно, т.к. этих троек получается очень много
(сотни тысяч), а каждый tuple требует дополнительной памяти::

Память под числа [-5...256] в CPython выделена заранее, но
>>> import sys
>>> sys.getsizeof(tuple())
56

* это деталь реализации CPython;
* в парадигмах много чисел не из этого интервала;
* list в питоне реализован через массив указателей, а значит требует
дополнительные 4 или 8 байт на элемент (на 32- и 64-битных системах).
В отличие от питоньего list, `array.array`_ хранится одним куском памяти,
накладные расходы меньше. В питоне list - массив указателей на объекты.

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

Связи между лексемами
---------------------
Expand All @@ -222,14 +188,15 @@
Упаковка слов
-------------

Для хранения данных о словах используется граф (Directed Acyclic Word Graph,
`wiki <http://en.wikipedia.org/wiki/Directed_acyclic_word_graph>`__)
Для хранения данных о словах используется конечный автомат
(Deterministic Acyclic Finite State Automaton,
`wiki <http://en.wikipedia.org/wiki/Deterministic_acyclic_finite_state_automaton>`__)
с использованием библиотек DAWG_ (это обертка над C++ библиотекой dawgdic_)
или DAWG-Python_ (это написанная на питоне реализация DAWG, которая не требует
компилятора для установки и работает быстрее DAWG_ под PyPy).

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

Expand Down Expand Up @@ -497,7 +464,7 @@ DAWG достаточно эффективная. Хранение слов в D

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

0 comments on commit 659a02a

Please sign in to comment.