From f1c3108d52e3306a8f2c91b1f71d9d69b88ac672 Mon Sep 17 00:00:00 2001 From: Julia Makogon Date: Mon, 25 Feb 2019 16:48:17 +0200 Subject: [PATCH] Fixing pymorphy2 dependency issue (#3329) (closes #3327) * Classes for Ukrainian; small fix in Russian. * Contributor agreement * pymorphy2 initialization split for ru and uk (#3327) * stop-words fixed * Unit-tests updated --- spacy/lang/ru/lemmatizer.py | 7 +- spacy/lang/uk/lemmatizer.py | 239 +++++++++++++++++++++++++- spacy/lang/uk/stop_words.py | 141 +++++++++++---- spacy/tests/conftest.py | 2 + spacy/tests/lang/uk/test_tokenizer.py | 2 +- 5 files changed, 348 insertions(+), 43 deletions(-) diff --git a/spacy/lang/ru/lemmatizer.py b/spacy/lang/ru/lemmatizer.py index 2cdf08e2e..24036310f 100644 --- a/spacy/lang/ru/lemmatizer.py +++ b/spacy/lang/ru/lemmatizer.py @@ -8,17 +8,18 @@ from ...lemmatizer import Lemmatizer class RussianLemmatizer(Lemmatizer): _morph = None - def __init__(self, pymorphy2_lang='ru'): + def __init__(self): super(RussianLemmatizer, self).__init__() try: from pymorphy2 import MorphAnalyzer except ImportError: raise ImportError( 'The Russian lemmatizer requires the pymorphy2 library: ' - 'try to fix it with "pip install pymorphy2==0.8"') + 'try to fix it with "pip install pymorphy2==0.8" ' + 'or "pip install git+https://github.com/kmike/pymorphy2.git pymorphy2-dicts-uk" if you need Ukrainian too') if RussianLemmatizer._morph is None: - RussianLemmatizer._morph = MorphAnalyzer(lang=pymorphy2_lang) + RussianLemmatizer._morph = MorphAnalyzer() def __call__(self, string, univ_pos, morphology=None): univ_pos = self.normalize_univ_pos(univ_pos) diff --git a/spacy/lang/uk/lemmatizer.py b/spacy/lang/uk/lemmatizer.py index 8db294507..fffae10c5 100644 --- a/spacy/lang/uk/lemmatizer.py +++ b/spacy/lang/uk/lemmatizer.py @@ -1,12 +1,239 @@ -from ..ru.lemmatizer import RussianLemmatizer +# coding: utf8 +from ...symbols import ( + ADJ, DET, NOUN, NUM, PRON, PROPN, PUNCT, VERB, POS +) +from ...lemmatizer import Lemmatizer -class UkrainianLemmatizer(RussianLemmatizer): +class UkrainianLemmatizer(Lemmatizer): + _morph = None - def __init__(self, pymorphy2_lang='ru'): + def __init__(self): + super(UkrainianLemmatizer, self).__init__() try: - super(UkrainianLemmatizer, self).__init__(pymorphy2_lang='uk') - except ImportError: + from pymorphy2 import MorphAnalyzer + if UkrainianLemmatizer._morph is None: + UkrainianLemmatizer._morph = MorphAnalyzer(lang='uk') + except (ImportError, TypeError): raise ImportError( 'The Ukrainian lemmatizer requires the pymorphy2 library and dictionaries: ' - 'try to fix it with "pip install git+https://github.com/kmike/pymorphy2.git pymorphy2-dicts-uk"') + 'try to fix it with' + '"pip uninstall pymorphy2"' + '"pip install git+https://github.com/kmike/pymorphy2.git pymorphy2-dicts-uk"') + + + def __call__(self, string, univ_pos, morphology=None): + univ_pos = self.normalize_univ_pos(univ_pos) + if univ_pos == 'PUNCT': + return [PUNCT_RULES.get(string, string)] + + if univ_pos not in ('ADJ', 'DET', 'NOUN', 'NUM', 'PRON', 'PROPN', 'VERB'): + # Skip unchangeable pos + return [string.lower()] + + analyses = self._morph.parse(string) + filtered_analyses = [] + for analysis in analyses: + if not analysis.is_known: + # Skip suggested parse variant for unknown word for pymorphy + continue + analysis_pos, _ = oc2ud(str(analysis.tag)) + if analysis_pos == univ_pos \ + or (analysis_pos in ('NOUN', 'PROPN') and univ_pos in ('NOUN', 'PROPN')): + filtered_analyses.append(analysis) + + if not len(filtered_analyses): + return [string.lower()] + if morphology is None or (len(morphology) == 1 and POS in morphology): + return list(set([analysis.normal_form for analysis in filtered_analyses])) + + if univ_pos in ('ADJ', 'DET', 'NOUN', 'PROPN'): + features_to_compare = ['Case', 'Number', 'Gender'] + elif univ_pos == 'NUM': + features_to_compare = ['Case', 'Gender'] + elif univ_pos == 'PRON': + features_to_compare = ['Case', 'Number', 'Gender', 'Person'] + else: # VERB + features_to_compare = ['Aspect', 'Gender', 'Mood', 'Number', 'Tense', 'VerbForm', 'Voice'] + + analyses, filtered_analyses = filtered_analyses, [] + for analysis in analyses: + _, analysis_morph = oc2ud(str(analysis.tag)) + for feature in features_to_compare: + if (feature in morphology and feature in analysis_morph + and morphology[feature] != analysis_morph[feature]): + break + else: + filtered_analyses.append(analysis) + + if not len(filtered_analyses): + return [string.lower()] + return list(set([analysis.normal_form for analysis in filtered_analyses])) + + @staticmethod + def normalize_univ_pos(univ_pos): + if isinstance(univ_pos, str): + return univ_pos.upper() + + symbols_to_str = { + ADJ: 'ADJ', + DET: 'DET', + NOUN: 'NOUN', + NUM: 'NUM', + PRON: 'PRON', + PROPN: 'PROPN', + PUNCT: 'PUNCT', + VERB: 'VERB' + } + if univ_pos in symbols_to_str: + return symbols_to_str[univ_pos] + return None + + def is_base_form(self, univ_pos, morphology=None): + # TODO + raise NotImplementedError + + def det(self, string, morphology=None): + return self(string, 'det', morphology) + + def num(self, string, morphology=None): + return self(string, 'num', morphology) + + def pron(self, string, morphology=None): + return self(string, 'pron', morphology) + + def lookup(self, string): + analyses = self._morph.parse(string) + if len(analyses) == 1: + return analyses[0].normal_form + return string + + +def oc2ud(oc_tag): + gram_map = { + '_POS': { + 'ADJF': 'ADJ', + 'ADJS': 'ADJ', + 'ADVB': 'ADV', + 'Apro': 'DET', + 'COMP': 'ADJ', # Can also be an ADV - unchangeable + 'CONJ': 'CCONJ', # Can also be a SCONJ - both unchangeable ones + 'GRND': 'VERB', + 'INFN': 'VERB', + 'INTJ': 'INTJ', + 'NOUN': 'NOUN', + 'NPRO': 'PRON', + 'NUMR': 'NUM', + 'NUMB': 'NUM', + 'PNCT': 'PUNCT', + 'PRCL': 'PART', + 'PREP': 'ADP', + 'PRTF': 'VERB', + 'PRTS': 'VERB', + 'VERB': 'VERB', + }, + 'Animacy': { + 'anim': 'Anim', + 'inan': 'Inan', + }, + 'Aspect': { + 'impf': 'Imp', + 'perf': 'Perf', + }, + 'Case': { + 'ablt': 'Ins', + 'accs': 'Acc', + 'datv': 'Dat', + 'gen1': 'Gen', + 'gen2': 'Gen', + 'gent': 'Gen', + 'loc2': 'Loc', + 'loct': 'Loc', + 'nomn': 'Nom', + 'voct': 'Voc', + }, + 'Degree': { + 'COMP': 'Cmp', + 'Supr': 'Sup', + }, + 'Gender': { + 'femn': 'Fem', + 'masc': 'Masc', + 'neut': 'Neut', + }, + 'Mood': { + 'impr': 'Imp', + 'indc': 'Ind', + }, + 'Number': { + 'plur': 'Plur', + 'sing': 'Sing', + }, + 'NumForm': { + 'NUMB': 'Digit', + }, + 'Person': { + '1per': '1', + '2per': '2', + '3per': '3', + 'excl': '2', + 'incl': '1', + }, + 'Tense': { + 'futr': 'Fut', + 'past': 'Past', + 'pres': 'Pres', + }, + 'Variant': { + 'ADJS': 'Brev', + 'PRTS': 'Brev', + }, + 'VerbForm': { + 'GRND': 'Conv', + 'INFN': 'Inf', + 'PRTF': 'Part', + 'PRTS': 'Part', + 'VERB': 'Fin', + }, + 'Voice': { + 'actv': 'Act', + 'pssv': 'Pass', + }, + 'Abbr': { + 'Abbr': 'Yes' + } + } + + pos = 'X' + morphology = dict() + unmatched = set() + + grams = oc_tag.replace(' ', ',').split(',') + for gram in grams: + match = False + for categ, gmap in sorted(gram_map.items()): + if gram in gmap: + match = True + if categ == '_POS': + pos = gmap[gram] + else: + morphology[categ] = gmap[gram] + if not match: + unmatched.add(gram) + + while len(unmatched) > 0: + gram = unmatched.pop() + if gram in ('Name', 'Patr', 'Surn', 'Geox', 'Orgn'): + pos = 'PROPN' + elif gram == 'Auxt': + pos = 'AUX' + elif gram == 'Pltm': + morphology['Number'] = 'Ptan' + + return pos, morphology + + +PUNCT_RULES = { + "«": "\"", + "»": "\"" +} diff --git a/spacy/lang/uk/stop_words.py b/spacy/lang/uk/stop_words.py index f5b85312f..97f7e3dbd 100644 --- a/spacy/lang/uk/stop_words.py +++ b/spacy/lang/uk/stop_words.py @@ -13,8 +13,10 @@ from __future__ import unicode_literals STOP_WORDS = set("""а або адже +аж але алло +б багато без безперервно @@ -23,6 +25,7 @@ STOP_WORDS = set("""а більше біля близько +бо був буває буде @@ -36,22 +39,27 @@ STOP_WORDS = set("""а були було бути -бывь в -важлива -важливе -важливий -важливі вам вами вас ваш ваша ваше +вашим +вашими +ваших ваші +вашій +вашого +вашої +вашому +вашою +вашу вгорі вгору вдалині +весь вже ви від @@ -66,7 +74,15 @@ STOP_WORDS = set("""а вони воно восьмий +все +всею +всі +всім +всіх всього +всьому +всю +вся втім г геть @@ -102,16 +118,15 @@ STOP_WORDS = set("""а досить другий дуже +дякую +е +є +ж же -життя з за завжди зазвичай -зайнята -зайнятий -зайняті -зайнято занадто зараз зате @@ -119,22 +134,28 @@ STOP_WORDS = set("""а звідси звідусіль здається +зі значить знову зовсім -ім'я +і +із +її +їй +їм іноді інша інше інший інших інші -її -їй їх +й його йому +каже ким +кілька кого кожен кожна @@ -143,13 +164,13 @@ STOP_WORDS = set("""а коли кому краще -крейдуючи -кругом +крім куди ласка +ледве лише -люди -людина +м +має майже мало мати @@ -164,20 +185,27 @@ STOP_WORDS = set("""а мій мільйонів мною +мого могти моє -мож +моєї +моєму +моєю може можна можно можуть -можхо мої -мор +моїй +моїм +моїми +моїх +мою моя на навіть навіщо +навколо навкруги нагорі над @@ -190,10 +218,21 @@ STOP_WORDS = set("""а наш наша наше +нашим +нашими +наших наші +нашій +нашого +нашої +нашому +нашою +нашу не небагато +небудь недалеко +неї немає нерідко нещодавно @@ -206,17 +245,22 @@ STOP_WORDS = set("""а них ні ніби +ніж +ній ніколи нікуди +нім нічого ну -нх нього +ньому о +обидва обоє один одинадцятий одинадцять +однак однієї одній одного @@ -225,11 +269,16 @@ STOP_WORDS = set("""а он особливо ось +п'ятий +п'ятнадцятий +п'ятнадцять +п'ять перед перший під пізніше пір +після по повинно подів @@ -240,18 +289,14 @@ STOP_WORDS = set("""а потім потрібно почала -прекрасне -прекрасно +початку при про просто проте проти -п'ятий -п'ятнадцятий -п'ятнадцять -п'ять раз +разу раніше рано раптом @@ -259,6 +304,7 @@ STOP_WORDS = set("""а роки років року +році сам сама саме @@ -271,15 +317,15 @@ STOP_WORDS = set("""а самого самому саму -світу свого своє +своєї свої своїй своїх свою -сеаой себе +сих сім сімнадцятий сімнадцять @@ -307,7 +353,17 @@ STOP_WORDS = set("""а також там твій +твого твоє +твоєї +твоєму +твоєю +твої +твоїй +твоїм +твоїми +твоїх +твою твоя те тебе @@ -319,15 +375,19 @@ STOP_WORDS = set("""а тисяч тих ті +тієї тією +тій тільки +тім +то тобі тобою того тоді той -том тому +тою треба третій три @@ -345,7 +405,6 @@ STOP_WORDS = set("""а усім усіма усіх -усію усього усьому усю @@ -363,6 +422,7 @@ STOP_WORDS = set("""а цими цих ці +цієї цій цього цьому @@ -375,11 +435,24 @@ STOP_WORDS = set("""а через четвертий чи +чиє +чиєї +чиєму +чиї +чиїй +чиїм +чиїми +чиїх +чий +чийого +чийому чим численна численне численний численні +чию +чия чого чому чотири @@ -392,6 +465,8 @@ STOP_WORDS = set("""а ще що щоб +щодо +щось я як яка @@ -400,5 +475,5 @@ STOP_WORDS = set("""а які якій якого -якщо -""".split()) +якої +якщо""".split()) diff --git a/spacy/tests/conftest.py b/spacy/tests/conftest.py index 2202a1823..ddc1aee9d 100644 --- a/spacy/tests/conftest.py +++ b/spacy/tests/conftest.py @@ -52,6 +52,7 @@ def RU(request): @pytest.fixture() def UK(request): pymorphy = pytest.importorskip('pymorphy2') + pymorphy_lang = pytest.importorskip('pymorphy2.lang') return util.get_lang_class('uk')() @pytest.fixture() @@ -183,6 +184,7 @@ def ru_tokenizer(): @pytest.fixture(scope='session') def uk_tokenizer(): pymorphy = pytest.importorskip('pymorphy2') + pymorphy_lang = pytest.importorskip('pymorphy2.lang') return util.get_lang_class('uk').Defaults.create_tokenizer() @pytest.fixture(scope='session') diff --git a/spacy/tests/lang/uk/test_tokenizer.py b/spacy/tests/lang/uk/test_tokenizer.py index ded8e9300..f4e45825c 100644 --- a/spacy/tests/lang/uk/test_tokenizer.py +++ b/spacy/tests/lang/uk/test_tokenizer.py @@ -82,7 +82,7 @@ def test_uk_tokenizer_splits_open_appostrophe(uk_tokenizer, text): assert len(tokens) == 2 assert tokens[0].text == "'" - +@pytest.mark.xfail # https://github.com/explosion/spaCy/issues/3327 @pytest.mark.parametrize('text', ["Тест''"]) def test_uk_tokenizer_splits_double_end_quote(uk_tokenizer, text): tokens = uk_tokenizer(text)