Commits

Mikhail Korobov committed 319d97a

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

  • Participants
  • Parent commits c319800

Comments (0)

Files changed (11)

benchmarks/speed.py

         for word, cnt in word_no_umlauts:
             morph.tag(word)
 
-    logger.info("    tagger.tag: %0.0f words/sec (with freq. info)", utils.measure(_run, total_usages, 3))
-    logger.info("    tagger.tag: %0.0f words/sec (without freq. info)", utils.measure(_run_nofreq, len(words), 3))
-    logger.info("    tagger.tag: %0.0f words/sec (without freq. info, input umlauts removed)", utils.measure(_run_no_umlauts, len(words), 3))
+    logger.info("    tagger.tag: %0.0f words/sec (with freq. info)", utils.measure(_run, total_usages))
+    logger.info("    tagger.tag: %0.0f words/sec (without freq. info)", utils.measure(_run_nofreq, len(words)))
+    logger.info("    tagger.tag: %0.0f words/sec (without freq. info, input umlauts removed)", utils.measure(_run_no_umlauts, len(words)))
 
 
 def bench_parse(morph, words, total_usages):
         for word, cnt in words:
             morph.parse(word)
 
-    logger.info("    tagger.parse: %0.0f words/sec (with freq. info)", utils.measure(_run, total_usages, 3))
-    logger.info("    tagger.parse: %0.0f words/sec (without freq. info)", utils.measure(_run_nofreq, len(words), 3))
+    logger.info("    tagger.parse: %0.0f words/sec (with freq. info)", utils.measure(_run, total_usages))
+    logger.info("    tagger.parse: %0.0f words/sec (without freq. info)", utils.measure(_run_nofreq, len(words)))
 
 def bench_all(dict_path=None):
     """ Run all benchmarks """

docs/internals/2trie.rst

 реализован схожий способ; впоследствие я заменил его на другой.
 
 Этот первоначальный формат словарей в моей реализации обеспечивал
-скорость разбора порядка 20-60тыс слов/сек при потреблении
+скорость разбора порядка 20-60тыс слов/сек (без предсказателя) при потреблении
 памяти 30М (с использованием datrie_), или порядка 2-5 тыс слов/сек
 при потреблении памяти 5M (с использованием marisa-trie_).
 
 
 * другой формат проще;
 * алгоритмы работы получаются проще;
-* скорость разбора получается больше (порядка 100-200 тыс слов/сек)
-  при меньшем потреблении памяти (порядка 15M).
+* скорость разбора получается больше (порядка 100-200 тыс слов/сек без
+  предсказателя) при меньшем потреблении памяти (порядка 15M).
 
 Но при этом первоначальный формат потенциально позволяет
 тратить меньше памяти; некоторые способы ускорения работы

docs/internals/analyze.rst

-Морфологический анализ
-======================
-
-.. warning::
-
-    Это заготовка для страницы со справкой!
-
-Задачи морфологического анализатора
------------------------------------
-
-pymorphy2 должен уметь
-
-1. для слова, которое есть в словаре, вернуть грам. информацию;
-2. для слова, которое есть в словаре, вернуть его нормальную форму;
-3. для слова, которое есть в словаре, вернуть его произвольную форму
-   (по данной грам. информации);
-4. для слова, которого нет в словаре, вернуть наиболее вероятную
-   грам. информацию;
-5. для слова, которого нет в словаре, вернуть нормальную форму;
-6. для слова, которого нет в словаре, вернуть произвольную форму
-   (по данной грам. информации).
-
-Исходя из этих задач была спроектирована
-:ref:`структура словаря <dictionary>`. Конкретные алгоритмы
-морфологического анализа и синтеза определяются структурой данных,
-в которую упакован словарь.

docs/internals/dict.rst

     завершением строки и не может присутствовать в ключе, а для
     двухбайтовых целых числел сложно гарантировать, что оба байта ненулевые.
 
+.. note::
+
+    Подход похож на тот, что описан на `aot.ru <http://aot.ru/>`_.
+
 
 .. _DAWG: https://github.com/kmike/DAWG
 .. _DAWG-Python: https://github.com/kmike/DAWG-Python
 
 В 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
+    есть :ref:`предсказатель <prediction>`.
+
+Формат хранения словаря
+-----------------------
+
+Итоговый словарь представляет собой папку с файлами::
+
+    dict/
+        meta.json
+        gramtab.json
+        suffixes.json
+        paradigms.array
+        words.dawg
+        prediction-suffixes.dawg
+        prediction-prefixes.dawg
+
+Файлы .json - обычные json-данные; .dawg - это двоичный формат C++ библиотеки
+`dawgdic`_; paradigms.array - это массив чисел в двоичном виде.
+
+.. note::
+
+    Если вы вдруг пишете морфологический анализатор не на питоне (и формат
+    хранения данных устраивает), то вполне возможно, что будет проще
+    использовать эти подготовленные словари, а не конвертировать словари
+    из OpenCorpora еще раз; ничего специфичного для питона
+    в сконвертированных словарях нет.
+
 Характеристики
 --------------
 
 После применения описанных выше методов в pymorphy2 словарь
 OpenCorpora занимает около 16Мб оперативной памяти и позволяет проводить
-анализ слов (по предварительным тестам; pymorphy2 еще не готов и
-скоростные характеристики могут измениться в обе стороны) со
-скоростью 50..150 тыс слов/сек. Для сравнения:
+анализ слов со скоростью порядка 50..150 тыс слов/сек. Для сравнения:
 
 * в mystem_ словарь + код занимает около 3Мб оперативной памяти,
   скорость > 100тыс. слов/сек;
 Цели обогнать C/C++ реализации у pymorphy2 нет; цель - скорость
 базового разбора должна быть достаточной для того, чтоб "продвинутые"
 операции работали быстро. Мне кажется, 100 тыс. слов/сек или 300 тыс.
-слов/сек - это не очень важно, т.к. накладные расходы в реальных задачах
-все равно, скорее всего, "съедят" эту разницу (особенно при использовании
-из питоньего кода).
+слов/сек - это не очень важно для многих задач, т.к. накладные расходы
+на обработку и применение результатов разбора все равно, скорее всего,
+"съедят" эту разницу (особенно при использовании из питоньего кода).
 
 .. _mystem: http://company.yandex.ru/technologies/mystem/
 .. _pymorphy 0.5.6: http://pymorphy.readthedocs.org/en/v0.5.6/index.html

docs/internals/index.rst

    :maxdepth: 2
 
    dict
-   analyze
+   prediction
    umlauts
    2trie

docs/internals/prediction.rst

+
+.. _prediction:
+
+Предсказатель
+=============
+
+TODO

pymorphy2/constants.py

 # -*- coding: utf-8 -*-
 from __future__ import absolute_import, unicode_literals
 
-LEMMA_PREFIXES = ["", 'ПО', 'НАИ']
+LEMMA_PREFIXES = ["", "ПО", "НАИ"]
 
 PREDICTION_PREFIXES = [
     "АНТИ",
     "ЭКСТРА"
 ]
 
-
-#NON_PRODUCTIVE_CLASSES = {
-#    'opencorpora_int': set(['NPRO', 'PRED', 'PREP', 'CONJ', 'PRCL', 'INTJ'])
-#}
-#
-#PRODUCTIVE_CLASSES_AOT = { # productive classes as described at aot.ru
-#    'opencorpora_int': set(['NOUN', 'VERB', 'INFN', 'ADJF', 'ADJS', 'COMP', 'ADVB'])
-#}
-
-#NOUNS = ('NOUN', 'С',)
-#PRONOUNS = ('PN', 'МС',)
-#PRONOUNS_ADJ = ('PN_ADJ', 'МС-П',)
-#VERBS = ('Г', 'VERB',  'ИНФИНИТИВ',)
-#ADJECTIVE = ('ADJECTIVE', 'П',)
-#
-#PRODUCTIVE_CLASSES = NOUNS + VERBS + ADJECTIVE + ('Н',)
+NON_PRODUCTIVE_CLASSES = {
+    'opencorpora-int': set(['NUMR', 'NPRO', 'PRED', 'PREP', 'CONJ', 'PRCL', 'INTJ'])
+}

pymorphy2/opencorpora_dict.py

         (lemmas_list, links, version, revision)
 
     """
-
     from lxml import etree
 
     links = []
     # XXX: this uses approach different from pymorphy 0.5.6;
     # what are the implications on prediction quality?
 
-    #endings = collections.defaultdict(set)
-
-#    def _degenerate_paradigm(para_id):
-#        para = paradigms[para_id]
-#        para_len = len(para) // 3
-#        return not (any(para[:para_len]) or any(para[para_len*2:]))
-
     productive_paradigms = set(
         para_id
         for (para_id, count) in popularity.items()
-        if count >= min_paradigm_popularity # and not _degenerate_paradigm(para_id)
+        if count >= min_paradigm_popularity
     )
 
     ending_counts = collections.Counter()

pymorphy2/tagger.py

 import os
 import collections
 from pymorphy2 import opencorpora_dict
-from pymorphy2.constants import LEMMA_PREFIXES
+from pymorphy2.constants import LEMMA_PREFIXES, NON_PRODUCTIVE_CLASSES
+from pymorphy2.tagset import get_POS
 
-def _split_word(word, min_reminder=3, max_prefix_length=5):
-    max_split = min(max_prefix_length, len(word)-min_reminder)
-    split_indexes = range(1, 1+max_split)
-    return [(word[:i], word[i:]) for i in split_indexes]
+#ParseResult = collections.namedtuple('ParseResult', 'fixed_word tag normal_form estimate')
 
 class Morph(object):
 
     def __init__(self, dct):
         self._dictionary = dct
         self._ee = dct.words.compile_replaces({'Е': 'Ё'})
+        self._non_productive_classes = NON_PRODUCTIVE_CLASSES[dct.meta['gramtab_format']]
 
     @classmethod
     def load(cls, path=None):
         Creates a Morph object using dictionaries at ``path``.
 
         If ``path`` is None then the path is obtained from
-        ``PYMORPHY2_DICT_PATH`` enviroment variable.
+        ``PYMORPHY2_DICT_PATH`` environment variable.
         """
         if path is None:
             if cls.env_variable not in os.environ:
         if not res:
             res = self._parse_as_word_with_known_prefix(word)
         if not res:
-            res = self._parse_as_word_with_unknown_prefix(word)
-        if not res:
-            res = self._parse_as_word_with_known_suffix(word)
+            seen = set()
+            res = self._parse_as_word_with_unknown_prefix(word, seen)
+            res.extend(self._parse_as_word_with_known_suffix(word, seen))
         return res
 
     def _parse_as_known(self, word):
+        """
+        Parses the word using a dictionary.
+        """
         res = []
         para_normal_forms = {}
 
                 tag = self._build_tag_info(para_id, idx)
 
                 res.append(
-                    (fixed_word, tag, normal_form)
+                    (fixed_word, tag, normal_form, 1.0)
                 )
 
         return res
 
     def _parse_as_word_with_known_prefix(self, word):
+        """
+        Parses the word by checking if it starts with a known prefix
+        and parsing the reminder.
+        """
         res = []
+        ESTIMATE_DECAY = 0.75
         word_prefixes = self._dictionary.prediction_prefixes.prefixes(word)
         for prefix in word_prefixes:
             unprefixed_word = word[len(prefix):]
-            parses = self.parse(unprefixed_word)
-            res.extend([
-                (prefix+fixed_word, tag, prefix+normal_form)
-                for (fixed_word, tag, normal_form) in parses
-            ])
-        return res
 
-    def _parse_as_word_with_unknown_prefix(self, word):
-        res = []
-        for prefix, truncated_word in _split_word(word):
-            parses = self._parse_as_known(truncated_word)
-            res.extend([
-                (prefix+fixed_word, tag, prefix+normal_form)
-                for (fixed_word, tag, normal_form) in parses
-            ])
+            for fixed_word, tag, normal_form, estimate in self.parse(unprefixed_word):
+                if get_POS(tag) in self._non_productive_classes:
+                    continue
+
+                parse = (prefix+fixed_word, tag, prefix+normal_form, estimate*ESTIMATE_DECAY)
+                res.append(parse)
 
         return res
 
-    def _parse_as_word_with_known_suffix(self, word):
+    def _parse_as_word_with_unknown_prefix(self, word, _seen_parses=None):
+        """
+        Parses the word by parsing only the word suffix
+        (with restrictions on prefix & suffix lengths).
+        """
+        if _seen_parses is None:
+            _seen_parses = set()
+        res = []
+        ESTIMATE_DECAY = 0.5
+        for prefix, unprefixed_word in _split_word(word):
+            for fixed_word, tag, normal_form, estimate in self._parse_as_known(unprefixed_word):
+
+                if get_POS(tag) in self._non_productive_classes:
+                    continue
+
+                parse = (prefix+fixed_word, tag, prefix+normal_form, estimate*ESTIMATE_DECAY)
+
+                reduced_parse = parse[:3]
+                if reduced_parse in _seen_parses:
+                    continue
+                _seen_parses.add(reduced_parse)
+
+                res.append(parse)
+
+        return res
+
+    def _parse_as_word_with_known_suffix(self, word, _seen_parses=None):
+        """
+        Parses the word by checking how the words with similar suffixes
+        are parsed.
+        """
+        if _seen_parses is None:
+            _seen_parses = set()
         result = []
+        ESTIMATE_DECAY = 0.5
         for i in 5,4,3,2,1:
             end = word[-i:]
             para_data = self._dictionary.prediction_suffixes.similar_items(end, self._ee)
 
+            total_cnt = 1 # smoothing; XXX: isn't max_cnt better?
             for fixed_suffix, parse in para_data:
                 for cnt, para_id, idx in reversed(parse):
+
+                    tag = self._build_tag_info(para_id, idx)
+
+                    if get_POS(tag) in self._non_productive_classes:
+                        continue
+
+                    total_cnt += cnt
+
                     fixed_word = word[:-i] + fixed_suffix
                     normal_form = self._build_normal_form(para_id, idx, fixed_word)
-                    tag = self._build_tag_info(para_id, idx)
-                    result.append(
-                        (cnt, fixed_word, tag, normal_form)
-                    )
 
-            if result:
-                result = [tpl[1:] for tpl in sorted(result, reverse=True)] # remove counts
+                    parse = (fixed_word, tag, normal_form, cnt)
+                    reduced_parse = parse[:3]
+                    if reduced_parse in _seen_parses:
+                        continue
+
+                    result.append(parse)
+
+            if total_cnt > 1:
+                result = [
+                    (fixed_word, tag, normal_form, cnt/total_cnt * ESTIMATE_DECAY)
+                    for (fixed_word, tag, normal_form, cnt) in sorted(result, reverse=True)
+                ]
                 break
 
         return result
 
     def normal_forms(self, word):
+        """
+        Returns a list of word normal forms.
+        """
         seen = set()
         result = []
-        for fixed_word, tag, normal_form in self.parse(word):
+        for fixed_word, tag, normal_form, estimate in self.parse(word):
             if normal_form not in seen:
                 result.append(normal_form)
                 seen.add(normal_form)
         return result
 
     # ====== tag ========
+    # XXX: one can use .parse method, but .tag is up to 2x faster.
 
     def tag(self, word):
         res = self._tag_as_known(word)
         if not res:
             res = self._tag_as_word_with_unknown_prefix(word)
         if not res:
-            res = self._tag_using_suffix(word)
+            res = self._tag_as_word_with_known_suffix(word)
         return res
 
     def _tag_as_known(self, word):
 
         return res
 
-    def _tag_using_suffix(self, word):
+    def _tag_as_word_with_known_suffix(self, word):
         result = []
         for i in 5,4,3,2,1:
             end = word[-i:]
     # ==== dictionary access utilities ===
 
     def _build_tag_info(self, para_id, idx):
+        """
+        Returns gram. tag as a string.
+        """
         paradigm = self._dictionary.paradigms[para_id]
-        tag_id = paradigm[len(paradigm) // 3 + idx]
+        tag_info_offset = len(paradigm) // 3
+        tag_id = paradigm[tag_info_offset + idx]
         return self._dictionary.gramtab[tag_id]
 
     def _build_paradigm_info(self, para_id):
+        """
+        Returns a list of
+
+            (prefix, tag, suffix)
+
+        tuples representing the paradigm.
+        """
         paradigm = self._dictionary.paradigms[para_id]
         paradigm_len = len(paradigm) // 3
         res = []
         return res
 
     def _build_normal_form(self, para_id, idx, fixed_word):
+        """
+        Builds a normal form.
+        """
 
         if idx == 0: # a shortcut: normal form is a word itself
             return fixed_word
     def meta(self):
         return self._dictionary.meta
 
+
+def _split_word(word, min_reminder=3, max_prefix_length=5):
+    """
+    Returns all splits of a word (taking in account min_reminder and
+    max_prefix_length).
+    """
+    max_split = min(max_prefix_length, len(word)-min_reminder)
+    split_indexes = range(1, 1+max_split)
+    return [(word[:i], word[i:]) for i in split_indexes]

pymorphy2/tagset.py

 Utils for working with grammatical tags.
 """
 from __future__ import absolute_import
-import re
-
-POS_RE = re.compile(" |,")
 
 def get_POS(tag):
     return tag.replace(' ', ',', 1).split(',', 1)[0]

tests/test_tagger.py

 # -*- coding: utf-8 -*-
 from __future__ import absolute_import, unicode_literals
 import pytest
-from pymorphy2 import tagger
-
-TEST_DATA = [
-    ('КОШКА', ['КОШКА']),
-    ('КОШКЕ', ['КОШКА']),
-
-    # в pymorphy 0.5.6 результат парсинга - наоборот, сначала СТАЛЬ, потом СТАТЬ
-    ('СТАЛИ', ['СТАТЬ', 'СТАЛЬ']),
-
-    ('НАИСТАРЕЙШИЙ', ['СТАРЫЙ']),
-
-    ('КОТЁНОК', ['КОТЁНОК']),
-    ('КОТЕНОК', ['КОТЁНОК']),
-    ('ТЯЖЕЛЫЙ', ['ТЯЖЁЛЫЙ']),
-    ('ЛЕГОК', ['ЛЁГКИЙ']),
-
-    ('ОНА', ['ОНА']),
-    ('ЕЙ', ['ОНА']),
-    ('Я', ['Я']),
-    ('МНЕ', ['Я']),
-
-    ('НАИНЕВЕРОЯТНЕЙШИЙ', ['ВЕРОЯТНЫЙ']),
-    ('ЛУЧШИЙ', ['ХОРОШИЙ']),
-    ('НАИЛУЧШИЙ', ['ХОРОШИЙ']),
-    ('ЧЕЛОВЕК', ['ЧЕЛОВЕК']),
-    ('ЛЮДИ', ['ЧЕЛОВЕК']),
-
-    ('КЛЮЕВУ', ['КЛЮЕВ']),
-    ('КЛЮЕВА', ['КЛЮЕВ']),
-
-    ('ГУЛЯЛ', ['ГУЛЯТЬ']),
-    ('ГУЛЯЛА', ['ГУЛЯТЬ']),
-    ('ГУЛЯЕТ', ['ГУЛЯТЬ']),
-    ('ГУЛЯЮТ', ['ГУЛЯТЬ']),
-    ('ГУЛЯЛИ', ['ГУЛЯТЬ']),
-    ('ГУЛЯТЬ', ['ГУЛЯТЬ']),
-
-    ('ГУЛЯЮЩИЙ', ['ГУЛЯТЬ']),
-    ('ГУЛЯВШИ', ['ГУЛЯТЬ']),
-    ('ГУЛЯЯ', ['ГУЛЯТЬ']),
-    ('ГУЛЯЮЩАЯ', ['ГУЛЯТЬ']),
-    ('ЗАГУЛЯВШИЙ', ['ЗАГУЛЯТЬ']),
-
-    ('КРАСИВЫЙ', ['КРАСИВЫЙ']),
-    ('КРАСИВАЯ', ['КРАСИВЫЙ']),
-    ('КРАСИВОМУ', ['КРАСИВЫЙ']),
-    ('КРАСИВЫЕ', ['КРАСИВЫЙ']),
-
-    ('ДЕЙСТВИЕ', ['ДЕЙСТВИЕ']),
-]
-
-PREFIX_PREDICTION_DATA = [
-    ('ПСЕВДОКОШКА', ['ПСЕВДОКОШКА']),
-    ('ПСЕВДОКОШКОЙ', ['ПСЕВДОКОШКА']),
-
-    ('СВЕРХНАИСТАРЕЙШИЙ', ['СВЕРХСТАРЫЙ']),
-    ('СВЕРХНАИСТАРЕЙШИЙ', ['СВЕРХСТАРЫЙ']),
-    ('КВАЗИПСЕВДОНАИСТАРЕЙШЕГО', ['КВАЗИПСЕВДОСТАРЫЙ']),
-    ('НЕБЕСКОНЕЧЕН', ['НЕБЕСКОНЕЧНЫЙ']),
-
-    ('МЕГАКОТУ', ['МЕГАКОТ']),
-    ('МЕГАСВЕРХНАИСТАРЕЙШЕМУ', ['МЕГАСВЕРХСТАРЫЙ']),
-]
-
-PREDICTION_TEST_DATA = [
-    ('ТРИЖДЫЧЕРЕЗПИЛЮЛЮОКНАМИ', ['ТРИЖДЫЧЕРЕЗПИЛЮЛЮОКНО']),
-    ('РАЗКВАКАЛИСЬ', ['РАЗКВАКАТЬСЯ']),
-    ('КАШИВАРНЕЕ', ['КАШИВАРНЫЙ']),
-    ('ДЕПЫРТАМЕНТОВ', ['ДЕПЫРТАМЕНТ']),
-    ('ИЗМОХРАТИЛСЯ', ['ИЗМОХРАТИТЬСЯ']),
-
-    ('БУТЯВКОЙ', ['БУТЯВКА', 'БУТЯВКОЙ']), # и никаких местоимений!
-    ('САПАЮТ', ['САПАТЬ']), # и никаких местоимений!
-]
+from pymorphy2 import tagger, tagset
 
 morph = tagger.Morph.load()
 
 
 class TestNormalForms(object):
 
+    TEST_DATA = [
+        ('КОШКА', ['КОШКА']),
+        ('КОШКЕ', ['КОШКА']),
+
+        # в pymorphy 0.5.6 результат парсинга - наоборот, сначала СТАЛЬ, потом СТАТЬ
+        ('СТАЛИ', ['СТАТЬ', 'СТАЛЬ']),
+
+        ('НАИСТАРЕЙШИЙ', ['СТАРЫЙ']),
+
+        ('КОТЁНОК', ['КОТЁНОК']),
+        ('КОТЕНОК', ['КОТЁНОК']),
+        ('ТЯЖЕЛЫЙ', ['ТЯЖЁЛЫЙ']),
+        ('ЛЕГОК', ['ЛЁГКИЙ']),
+
+        ('ОНА', ['ОНА']),
+        ('ЕЙ', ['ОНА']),
+        ('Я', ['Я']),
+        ('МНЕ', ['Я']),
+
+        ('НАИНЕВЕРОЯТНЕЙШИЙ', ['ВЕРОЯТНЫЙ']),
+        ('ЛУЧШИЙ', ['ХОРОШИЙ']),
+        ('НАИЛУЧШИЙ', ['ХОРОШИЙ']),
+        ('ЧЕЛОВЕК', ['ЧЕЛОВЕК']),
+        ('ЛЮДИ', ['ЧЕЛОВЕК']),
+
+        ('КЛЮЕВУ', ['КЛЮЕВ']),
+        ('КЛЮЕВА', ['КЛЮЕВ']),
+
+        ('ГУЛЯЛ', ['ГУЛЯТЬ']),
+        ('ГУЛЯЛА', ['ГУЛЯТЬ']),
+        ('ГУЛЯЕТ', ['ГУЛЯТЬ']),
+        ('ГУЛЯЮТ', ['ГУЛЯТЬ']),
+        ('ГУЛЯЛИ', ['ГУЛЯТЬ']),
+        ('ГУЛЯТЬ', ['ГУЛЯТЬ']),
+
+        ('ГУЛЯЮЩИЙ', ['ГУЛЯТЬ']),
+        ('ГУЛЯВШИ', ['ГУЛЯТЬ']),
+        ('ГУЛЯЯ', ['ГУЛЯТЬ']),
+        ('ГУЛЯЮЩАЯ', ['ГУЛЯТЬ']),
+        ('ЗАГУЛЯВШИЙ', ['ЗАГУЛЯТЬ']),
+
+        ('КРАСИВЫЙ', ['КРАСИВЫЙ']),
+        ('КРАСИВАЯ', ['КРАСИВЫЙ']),
+        ('КРАСИВОМУ', ['КРАСИВЫЙ']),
+        ('КРАСИВЫЕ', ['КРАСИВЫЙ']),
+
+        ('ДЕЙСТВИЕ', ['ДЕЙСТВИЕ']),
+    ]
+
+    PREFIX_PREDICTION_DATA = [
+        ('ПСЕВДОКОШКА', ['ПСЕВДОКОШКА']),
+        ('ПСЕВДОКОШКОЙ', ['ПСЕВДОКОШКА']),
+
+        ('СВЕРХНАИСТАРЕЙШИЙ', ['СВЕРХСТАРЫЙ']),
+        ('СВЕРХНАИСТАРЕЙШИЙ', ['СВЕРХСТАРЫЙ']),
+        ('КВАЗИПСЕВДОНАИСТАРЕЙШЕГО', ['КВАЗИПСЕВДОСТАРЫЙ']),
+        ('НЕБЕСКОНЕЧЕН', ['НЕБЕСКОНЕЧНЫЙ']),
+
+        ('МЕГАКОТУ', ['МЕГАКОТ']),
+        ('МЕГАСВЕРХНАИСТАРЕЙШЕМУ', ['МЕГАСВЕРХСТАРЫЙ']),
+    ]
+
+    PREDICTION_TEST_DATA = [
+        ('ТРИЖДЫЧЕРЕЗПИЛЮЛЮОКНАМИ', ['ТРИЖДЫЧЕРЕЗПИЛЮЛЮОКНО']),
+        ('РАЗКВАКАЛИСЬ', ['РАЗКВАКАТЬСЯ']),
+        ('КАШИВАРНЕЕ', ['КАШИВАРНЫЙ']),
+        ('ДЕПЫРТАМЕНТОВ', ['ДЕПЫРТАМЕНТ', 'ДЕПЫРТАМЕНТОВЫЙ']),
+        ('ИЗМОХРАТИЛСЯ', ['ИЗМОХРАТИТЬСЯ']),
+
+        ('БУТЯВКОЙ', ['БУТЯВКА', 'БУТЯВКОЙ']), # и никаких местоимений!
+        ('САПАЮТ', ['САПАТЬ']), # и никаких местоимений!
+    ]
+
     @with_test_data(TEST_DATA)
     def test_normal_forms(self, word, parse_result):
         assert morph.normal_forms(word) == parse_result
         assert morph.normal_forms(word) == parse_result
 
 
+class TestParse(object):
+
+    def _parsed_as(self, parse, cls):
+        return any(tagset.get_POS(p[1])==cls for p in parse)
+
+    def assertNotParsedAs(self, word, cls):
+        parse = morph.parse(word)
+        assert not self._parsed_as(parse, cls), (parse, cls)
+
+    def test_no_nonproductive_forms(self):
+        self.assertNotParsedAs('БЯКОБЫ', 'PRCL')
+        self.assertNotParsedAs('БЯКОБЫ', 'CONJ')
+
+    def test_no_nonproductive_forms2(self):
+        self.assertNotParsedAs('ПСЕВДОЯКОБЫ', 'PRCL')
+        self.assertNotParsedAs('ПСЕВДОЯКОБЫ', 'CONJ')
+
+    def test_no_duplicate_parses(self):
+        parse = morph.parse('БУТЯВКОЙ')
+        data = [variant[:3] for variant in parse]
+        assert len(set(data)) == len(data), parse
+
+
 class TestTagWithPrefix(object):
 
     def test_tag_with_unknown_prefix(self):