#!/usr/bin/env python # coding: utf-8 # # Оценка качества разбора # In[1]: import os import re import glob import json from pymorphy2 import MorphAnalyzer from pymystem3 import Mystem import russian_tagsets # ## Версии программ: # # * pymorphy2 - https://github.com/kmike/pymorphy2, коммит 7c3bd6d27d23e1b4fa5cfb0f3d4bb604b6f3e68b # * pymorphy2-dicts-ru == 2.4.393442.3710985 # * mystem v3.0 # * pymystem == 0.1.2 # * russian_tagsets == 0.5.2 # In[2]: # pymorphy2 morph = MorphAnalyzer(lang='ru') morph_noprob = MorphAnalyzer(lang='ru', probability_estimator_cls=None) # mystem mystem = Mystem(disambiguation=False, grammar_info=True) mystem._mystemargs.remove('-gi') mystem._mystemargs.remove('-c') mystem._mystemargs += ['-i', '--eng-gr'] # ### Преобразование тегов к общему формату # # Участвует 3 тегсета: OpenCorpora (pymorphy2 и часть размеченных данных), НКРЯ (часть размеченных данных) и mystem. # # Библиотека russian-tagsets умеет преобразовывать из формата OpenCorpora в формат НКРЯ. # Из НКРЯ все приводим к формату mystem. # In[3]: _pym2ruscorpora = russian_tagsets.converters.converter('opencorpora-int', 'ruscorpora') def ruscorpora2mystem(tag): """ Convert ruscorpora.ru tag to mystem tag """ tag = tag.replace('-', '').replace('zoon', 'persn') tag = tag.replace('loc2', 'LOC').replace('loc', 'abl').replace('LOC', 'loc') tag = tag.replace('fut', 'inpraes') # ~sort of tag = tag.replace('gen2', 'part') tag = tag.replace('PARENTH', 'parenth').replace('PRAEDIC', 'praed') return tag def py2mystem(tag): """ Convert pymorphy2 tag to mystem tag. """ tag = _pym2ruscorpora(str(tag)) return ruscorpora2mystem(tag) _tag2grammemes = re.compile('[,=]').split tag2grammemes = lambda tag: _tag2grammemes(tag) def mystem_analyze(token): """ Analyze a single token using mystem. Return None if mystem analyzes the token as multiple tokens. """ res = mystem.analyze(token) if res[0]['text'] != token: return None result = res[0]['analysis'] for p in result: if 'gr' in p: p['gr'] = p['gr'].rstrip('=') return result def pymorphy2_analyze(token, prob=True): m = morph if prob else morph_noprob return [ {'gr': py2mystem(p.tag), 'lex': p.normal_form} for p in m.parse(token) ] # In[4]: mystem_analyze('друзьях') # In[5]: pymorphy2_analyze('друзьях') # ### Тестовый корпус # # Для оценки качества собран корпус из 2 частей: # # * https://github.com/kmike/microcorpus - 100 случайно выбранных предложений из OpenCorpora, размеченных вручную (это не то же самое, что 100 случайных предложение из размеченной части OpenCorpora!); # * 100 случайных предложений со снятой неоднозначностью из какой-то старой выгрузки НКРЯ. # In[6]: def read_microcorpus_file(path): with open(path, 'rt', encoding='utf8') as f: sent = [line.split(' ', 1) for line in f if line.strip()] return [(tok.strip(), tag.strip()) for tok, tag in sent] def read_ruscorpora_json(path): with open(path, 'rt', encoding='utf8') as f: return json.load(f) sents_microcorpus_src = [ read_microcorpus_file(path) for path in glob.glob('./microcorpus-done/*.txt') ] sents_ruscorpora_src = read_ruscorpora_json('./ruscorpora-100-fixed.json') sents_microcorpus = [ [(tok, py2mystem(tag)) for tok, tag in sent] for sent in sents_microcorpus_src ] sents_ruscorpora = [ [(tok, ruscorpora2mystem(tag)) for tok, tag in sent] for sent in sents_ruscorpora_src ] sents = sents_microcorpus + sents_ruscorpora print("microcorpus: %d sents; ruscorpora: %d sents" % (len(sents_microcorpus), len(sents_ruscorpora))) # ### Типы токенов # # Пунктуация, цифры и латинские слова не учитываются. # In[7]: def to_tokens(sents): tokens = [(tok, tag2grammemes(tag)) for sent in sents for (tok, tag) in sent] tokens = [(tok, gr) for tok, gr in tokens if not (set(gr) & {'PNCT', 'NONLEX', 'ciph'})] return tokens tokens = to_tokens(sents) tokens_microcorpus = to_tokens(sents_microcorpus) tokens_ruscorpora = to_tokens(sents_ruscorpora) # In[8]: print( "Total tokens: %d (%d microcorpus + %d ruscorpora)" % ( len(tokens), len(tokens_microcorpus), len(tokens_ruscorpora)) ) # ### Сопоставление тегов # # Учитываются теги целиком - оценивается качество полного морфологического анализа. # # При этом из-за того, что приходится преобразовывать 3 различных набора тегов друг в друга, некоторые различия различиями не считаются - различия могут быть вызваны неточностью преобразования тегов или разными подходами к разметке. Чтоб понять, зачем каждое из условий, можно его закомментировать и посмотреть, какие появятся дополнительные несоответствия в разборах. # In[9]: def _gram(p): """ Extract grammemes from a parse result. """ if isinstance(p, dict): return tag2grammemes(p['gr']) if isinstance(p, str): return tag2grammemes(p) return p def tags_diff(t1, t2): """ Return a set of grammemes which are different between t1 and t2, taking conversion issues in account. """ gr1 = set(_gram(t1)) gr2 = set(_gram(t2)) diff = gr1 ^ gr2 comb = gr1 | gr2 common = gr1 & gr2 diff -= {'anim', 'inan', 'persn', 'famn', '0', 'obsol', 'geo', 'distort', 'med', 'act', 'plen'} if diff == {'ADV'} and ({'parenth', 'praed'} & comb): return {} if diff == {'PART'} and 'parenth' in comb: return {} if diff == {'parenth'}: return {} if diff == {'CONJ', 'parenth'}: return {} if diff == {'inpraes', 'praes'} and 'ipf' in comb: return {} if diff == {'fut', 'inpraes', 'ipf'}: return {} if diff == {'tran'} or diff == {'inpraes', 'praes', 'tran'}: return {} if diff == {'ipf'}: return {} if 'S' in diff and 'INIT' in diff and 'abbr' in common: return {} if diff == {'SPRO', 'APRO'}: return {} if 'SPRO' in common: return {} if 'APRO' in common: return {} if diff == {'A', 'NUM'}: return {} if diff == {'praed', 'ADV'}: return {} if diff == {'praed', 'A'}: return {} if diff == {'APRO', 'ANUM', 'sg'}: return {} if diff == {'ADV', 'ADVPRO'}: return {} if diff == {'A', 'ADV'} and 'comp' in common: return {} if diff == {'A', 'pl', 'brev', 'ADV'}: return {} if diff == {'mf', 'm'}: return {} if diff == {'abbr'} or diff == {'abbr', 'f'}: return {} if diff == {'f'} or diff == {'m'} and 'abbr' in common: return {} return diff def tags_match(t1, t2): """ Return True if t1 and t2 tags are the same (taking in account tagset conversion issues). """ return not tags_diff(t1, t2) def has_correct(correct, parses): if parses is None: # mystem can't parse most hyphenated words as a single token; # don't consider it an error return True for p in parses: if tags_match(p, correct): return True return False def is_bad(correct, parses): return not has_correct(correct, parses) # ### Поиск ошибок разбора # # Если среди предложенных морфологическим анализатором вариантов нет совпадающего с правильным, то разбор считается неправильным. # # Это предварительный шаг - все ошибки будут потом еще раз проверены вручную. # # Примечание: в выборке из НКРЯ было найдено 6 ошибок на 100 предложений; вот что устранено: # # * ребята - почему-то средний род; # * направленные - откуда inan? Видимо, имелось в виду intr; # * достигает - почему-то непереходный; # * следовательно - это скорее союз/вводное слово; # * "еще полвека назад" и "уже полгода как" - винительный падеж, а не родительный; # In[10]: pymorphy2_errors = [(tok, gr) for tok, gr in tokens if is_bad(gr, pymorphy2_analyze(tok))] mystem_errors = [(tok, gr) for tok, gr in tokens if is_bad(gr, mystem_analyze(tok))] py_err = len(pymorphy2_errors) my_err = len(mystem_errors) print("pymorphy2: %d errors ==> %0.1f%% has correct results" % (py_err, 100*(1-py_err/len(tokens)))) print("mystem: %d errors ==> %0.1f%% has correct results" % (my_err, 100*(1-my_err/len(tokens)))) print("Note: not all errors are real errors; see below") # In[11]: pymorphy2_errors # In[12]: mystem_errors # ## Результаты ручной проверки # # ### Не учитываем как ошибки # # pymorphy2: # # * один из "дыа", т.к. это одна и та же ошибка 2 раза; # * слово "также". В разметке - частица, pymorphy2 говорит, что союз. # # mystem: # # * "Донского" - непонятно, существительное это (фамилия), или прилагательное. mystem считает, что прилагательное, в разметке и у pymorphy2 - существительное. # * один из "дыа", т.к. это одна и та же ошибка 2 раза; # * две из трех форм "проживающие", т.к. это одна и та же ошибка, но 3 раза; # * то, что mystem отказался разбирать как единые токены: интернет-порталом, российско-азербайджанским, О’Хара, вулканогенно-осадочные, инновационно-образовательной, иссиня-белого, Экономико-расселенческий, фитнесс-мероприятием, онлайн-магазин, М-Видео, интернет-универмагом - всего 11 случаев. Исключение - учитывается токен "Та-а-ак", который засчитался как ошибка в pymorphy2. # * полвека, полгода - mystem считает, что это множественное число, что допустимо. # # ### Итоговый набор ошибок (microcorpus+НКРЯ=итого): # # pymorphy2: **10+9=19** (или **7+9=16** без учета сокращений) # # * 3+0 - сокращения (т.; г; св.) # * 2+4 - аббривеатуры (ПРО; ВОВ; МБП; НПФ; СБ; ОМУ) # * 0+1 - предсказатели (фитнесса); # * 0+2 - сомнительные разборы для словарных слов (полгода, полвека) - у них стоит средний род # * 0+1 - ошибки на переходность (направленные) # * 3+1 - имена/фамилии (Юнг, Малхолланда, отец Диана, Слуцкер) # * 2+0 - искаженные слова (Дыа, Та-а-ак) # # mystem: **15+8=23** (или **8+6=14** без учета сокращений) # # * 7+2 - сокращения (т.п.; т.е.; т.д.; г; св.; млн;) # * 1+4 - аббривеатуры (ПРО; МБП; НПФ; СБ; ОМУ) # * 2+0 - предсказатели (Прожекторперисхилтон, снарягу) # * 1+0 - сомнительные разборы для словарных слов (скорее) # * 1+2 - ошибки на переходность (проживающие, изменяет, направленные) # * 1+0 - имена/фамилии (отец Диана) # * 2+0 - искаженные слова (Дыа, Та-а-ак) # # В ручной разметке НКРЯ (случайная выборка из 100 предложений, какая-то старая выгрузка) было 6 ошибок. # In[ ]: # ## Снятие неоднозначности # # ** ==ЭТО ТОЛЬКО НАБРОСОК== ** # # pymorphy2 умеет снимать неоднозначность на уровне отдельных слов (без учета контекста). # Набросок кода для оценки качества (набросок, т.к. там, скорее всего, всякие ошибки из-за преобразования тегов из одного тегсета в другой): # In[13]: def POS_match(t1, t2): # FIXME: code is a copy-paste of tags_diff with minor variations gr1, gr2 = _gram(t1), _gram(t2) pos1, pos2 = gr1[0], gr2[0] if pos1 == pos2: return True diff = set(gr1) ^ set(gr2) comb = set(gr1) | set(gr2) common = set(gr1) & set(gr2) diff -= {'anim', 'inan', 'persn', 'famn', '0', 'obsol', 'geo', 'distort', 'med', 'act', 'plen'} if not diff: return True if diff == {'ADV'} and ({'parenth', 'praed'} & comb): return True if diff == {'PART'} and 'parenth' in comb: return True if 'S' in diff and 'INIT' in diff and 'abbr' in common: return True if diff == {'CONJ', 'parenth'}: return True if diff == {'SPRO', 'APRO'}: return True if diff == {'A', 'NUM'}: return True if diff == {'APRO', 'ANUM', 'sg'}: return True if diff == {'A', 'ADV'} and 'comp' in common: return True if 'SPRO' in common: return True if diff == {'ADV', 'ADVPRO'}: return True if diff == {'A', 'pl', 'brev', 'ADV'}: return True if diff == {'praed', 'ADV'}: return True if diff == {'praed', 'A'}: return True return False def first_correct(correct, parses): if parses is None: return True if not parses: return False return tags_match(parses[0], correct) def first_POS_correct(correct, parses): if parses is None: return True if not parses: return False return POS_match(parses[0], correct) # In[15]: pymorphy2_disambig_errors = [ (tok, gr) for tok, gr in tokens if not first_correct(gr, pymorphy2_analyze(tok)) ] pymorphy2_noprob_disambig_errors = [ (tok, gr) for tok, gr in tokens if not first_correct(gr, pymorphy2_analyze(tok, prob=False)) ] pymorphy2_POS_disambig_errors = [ (tok, gr) for tok, gr in tokens if not first_POS_correct(gr, pymorphy2_analyze(tok)) ] pymorphy2_noprob_POS_disambig_errors = [ (tok, gr) for tok, gr in tokens if not first_POS_correct(gr, pymorphy2_analyze(tok, prob=False)) ] def perc_txt(errors): percent = 100 - (len(errors) / len(tokens) * 100) return "%0.1f%%" % percent print("pymorphy2 context-unaware disambiguation, % of correct analyses\n") print("no P(tag|word): %s (full tagset), %s (POS only)" % ( perc_txt(pymorphy2_noprob_disambig_errors), perc_txt(pymorphy2_noprob_POS_disambig_errors))) print("with P(tag|word): %s (full tagset), %s (POS only)" % ( perc_txt(pymorphy2_disambig_errors), perc_txt(pymorphy2_POS_disambig_errors))) # In[ ]: