Расставляем ударения с помощью Natasha и Spacy

Представьте себя на месте изучающего русский язык иностранца. Ударение станет одним из ваших самых страшных ночных кошмаров. Во-первых, оно не описывается каким-то простым набором правил, и чаще всего правильное произношение приходится просто запоминать. Во-вторых, оно обычно не обозначается в текстах, что практически сводит на нет относительную близость русской орфографии к произношению — без ударений правильно прочитать текст с незнакомыми словами иностранец все равно не сможет. В-третьих, неправильное ударение сильно меняет фонетический образ слова для русского человека, и из-за одной ошибки вас могут просто не понять.

Мой опыт

Я знаком с одной девушкой, которая изучает русский язык в университете Лейдена. Однажды я написал приложение для изучения русского, одной из функций которого была расстановка ударений. Я просто проверял каждое слово по словарю. Однако, это часто приводило к ситуации неоднозначности, когда ударение зависит от контекста. Например: «два сло́ва», но «длинные слова́». В подобных ситуациях мне ничего не оставалось делать, кроме как оставлять слово без ударения.

Идея

Но ведь неоднозначность возникает из-за того, что ударение зависит от того, в какой форме употреблено слово, от его морфологических показателей. Если мы сможем найти словарь ударений в зависимости от формы слова + научимся определять форму слова по контексту, то мы сможем разрешить неоднозначность. Морфологический анализ — одна из стандартных задач NLP, для ее решения воспользуемся библиотекой Spacy.

Данные

Где же нам найти подходящий словарь ударений? Интересующая нас информация есть в wiktionary.

Wiktionary
Wiktionary

Для парсинга wiktionary существует либа wiktionaryparser. Впрочем, для наших целей подойдёт уже готовый результат её работы. Json-файл содержит набор словоформ, для каждой из которых доступно ее написание с ударением, набор определений, а также маркер части речи. Кроме того, я распарсил словарь Зализняка и добавил те формы оттуда, которых не нашлось в wiktionary.

Для одного токена может быть несколько объектов-словоформ, которые отличаются частью речи (военный как прилагательное и военный как существительное) или смыслом (писа́ть и пи́сать).

Новый формат словаря
Новый формат словаря

Я перевел словарь в новый формат: каждому токену соответствует массив словоформ, для каждой словоформы определены поля accentuated (вид словоформы с ударением), form (морфологические показатели) и lemma (ссылка на лемму). Словарь сериализован с помощью pickle в wordforms.dat.

Словарь лемм
Словарь лемм

Кроме этого, я создал словарь лемм. Леммы содержат массив возможных частей речи + частотный ранг. Словарь лемм также сериализован с помощью pickle, в lemmas.dat.

Spacy

Spacy — это библиотека для NLP. Она умеет делать такие вещи как токенизация, морфологический анализ, синтаксический анализ, Named Entity Recognition. Нас интересуют первые две функции.

Мы будем использовать natasha-spacy, реализацию русского языка для spacy на основе natasha, поскольку она поддерживает более сложный морфологический анализ (падежи, времена), чем стандартная версия. Инструкция по настройке находится здесь.

Инициализация

import spacy
import pickle
ru_nlp = spacy.load('ru_core_news_md') def load(): with open(file="lemmas.dat", mode='rb') as f: lemmas = pickle.loads(f.read()) with open(file="wordforms.dat", mode='rb') as f: wordforms = pickle.loads(f.read()) return lemmas, wordforms def introduce_special_cases_from_dictionary(dictionary): for word in dictionary: if (" " in word) or ("-" in word): if len(dictionary[word]) == 1: ru_nlp.tokenizer.add_special_case(word, [{"ORTH": dictionary[word][0]["accentuated"]}]) ru_nlp.tokenizer.add_special_case(word.capitalize(), [{"ORTH": dictionary[word][0]["accentuated"].capitalize()}]) lemmas, wordforms = load()
introduce_special_cases_from_dictionary(wordforms) f = open("in.txt", mode='r', encoding='utf-8')
text = f.read()
f.close() res = accentuate(text, wordforms, lemmas) f = open("out.txt", mode='w', encoding='utf-8')
f.write(res)
f.close()

Сначала загрузим наши словари, затем модифицируем токенизатор: добавим нестандартные (содержащие дефис или пробел) токены из нашего словаря. Это нужно из-за случаев наподобие «по-моему»: иначе слово будет разделено на «по», «-» и «моему», с неправильным ударением в «моему».

Токенизация и морфологический анализ

def tokenize(text, wordforms): res = [] doc = ru_nlp(text) for token in doc: if token.pos_ != 'PUNCT': word = {"token": token.text, "tag": token.tag_} if word["token"] in wordforms: word["interpretations"] = wordforms[word["token"]] if word["token"].lower() in wordforms: word["interpretations"] = wordforms[word["token"].lower()] word["lemma"] = token.lemma_ word["is_punctuation"] = False word["uppercase"] = word["token"].upper() == word["token"] word["starts_with_a_capital_letter"] = word["token"][0].upper() == word["token"][0] else: word = {"token": token.text, "is_punctuation": True} word["whitespace"] = token.whitespace_ res.append(word) return res

Делаем одновременно токенизацию и морфологический анализ.

Каждое слово представляем в виде словаря со значениями:

  • token — собственно запись словоформы (в нижнем регистре)

  • tag — набор морфологических показателей. Строка tag может выглядеть, например, так: «NOUN__Animacy=Inan|Case=Gen|Gender=Masc|Number=Plur» или «‘VERB__Aspect=Perf|Mood=Imp|Number=Plur|Person=Second|VerbForm=Fin|Voice=Act'»

  • interpretations — набор объектов из словаря (возможно, с различными ударениями), которыми может быть наш токен

  • lemma — исходная лемма токена с точки зрения spacy

  • is_punctuation — признак принимает значение True не только для знаков пунктуации, но и для «специальных случаев» (см. предыдущий раздел про инициализацию spacy).

  • starts_with_a_capital_letter — мы переводим все токены в нижний регистр, и отдельно запоминаем, а было ли слово изначально написано с большой буквы

  • uppercase — написано ли слово полностью в верхнем регистре

  • whitespace — содержит пробел, если после токена идёт пробел. Нужно для восстановления результата

Совместимость морфологических показателей

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

def compatible(interpretation, lemma, tag, lemmas): if lemma in lemmas: pos_exists = False possible_poses = lemmas[lemma]["pos"] for i in range(len(possible_poses)): if possible_poses[i] in tag: pos_exists = True break if not (pos_exists): return False if interpretation == "canonical": return True if "plural" in interpretation and not ("Number=Plur" in tag): return False if "singular" in interpretation and not ("Number=Sing" in tag): return False if not ("nominative" in interpretation) and ("Case=Nom" in tag): return False if not ("genitive" in interpretation) and ("Case=Gen" in tag): return False if not ("dative" in interpretation) and ("Case=Dat" in tag): return False if not ("accusative" in interpretation) and ("Case=Acc" in tag): adj = False if "ADJ" in tag and "Animacy=Inan" in tag: adj = True if not adj: return False if not ("instrumental" in interpretation) and ("Case=Ins" in tag): return False if not ("prepositional" in interpretation) and not ("locative" in interpretation) and ("Case=Loc" in tag): return False if (("present" in interpretation) or ("future" in interpretation)) and ("Tense=Past" in tag): return False if (("past" in interpretation) or ("future" in interpretation)) and ("Tense=Pres" in tag): return False if (("past" in interpretation) or ("present" in interpretation)) and ("Tense=Fut" in tag): return False return True

Аргументы:

  • interpretation — строка с морфологическими показателями из словаря wordforms. Пример: «genitive plural»

  • lemma — лемма токена по версии natasha-spacy

  • tag — морфологический тег от spacy. Пример: «NOUN__Animacy=Inan|Case=Gen|Gender=Masc|Number=Plur»

  • lemmas — словарь лемм

Сначала проверим, что лемма lemma вообще может быть частью речи, указанной в tag. Это позволяет отфильтровать случаи вроде «потом» как наречие, чтобы не интерпретировать его как форму слова «пот».

Далее, проверяем различные несовместимые условия (только если interpretation не ‘canonical’):

  • В interpretation написано и в tag явно указано разное грамматическое число

  • В tag указан падеж, а в interpretation соответствующего падежа нет

  • Явное противоречие во времени глагола

Обрабатываем все токены

def accentuate(text, wordforms, lemmas): res = "" words = tokenize(text, wordforms) for i in range(len(words)): accentuated = accentuate_word(words[i], lemmas) if "starts_with_a_capital_letter" in words[i] and words[i]["starts_with_a_capital_letter"]: accentuated = accentuated.capitalize() if "uppercase" in words[i] and words[i]["uppercase"]: accentuated = accentuated.upper() res += accentuated res += words[i]["whitespace"] return res def accentuate_word(word, lemmas): if ("tag" in word) and ("PROPN" in word["tag"]): return word["token"] if word["is_punctuation"] or (not "interpretations" in word): return word["token"] else: res = derive_single_accentuation(word["interpretations"]) if not (res is None): return res else: compatible_interpretations = [] for i in range(len(word["interpretations"])): if compatible(word["interpretations"][i]["form"], word["interpretations"][i]["lemma"], word["tag"], lemmas): compatible_interpretations.append(word["interpretations"][i]) res = derive_single_accentuation(compatible_interpretations) if not (res is None): return res else: new_compatible_interpretations = [] for i in range(len(compatible_interpretations)): if compatible_interpretations[i]["lemma"] == word["lemma"]: new_compatible_interpretations.append(compatible_interpretations[i]) res = derive_single_accentuation(new_compatible_interpretations) if not (res is None): return res else: return word["token"] def derive_single_accentuation(interpretations): if len(interpretations) == 0: return None res = interpretations[0]["accentuated"] for i in range(1, len(interpretations)): if interpretations[i]["accentuated"] != res: return None return res

В accentuate_word сначала проверяем, не является ли слово именем собственным. Если является, ничего с ним не делаем. Если этого не делать, могут возникнуть случаи вроде интерпретации «Же́не» как «жене́».

derive_single_interpretation проверяет, существует ли единственный способ постановки ударения. Если да, она возвращает этот способ, иначе возвращается None.

Постановка ударения происходит в 3 этапа

  • Если у нас сразу получилось так, что можно однозначно поставить ударение, то мы ничего дальше не делаем. Большинство слов в реальных текстах будут попадать в эту категорию.

  • Если есть различные варианты ударения, отфильтруем interpretations, оставив только те, которые проходят процедуру compatible. После данного этапа снова проверяем, остался ли у нас лишь один вариант ударения.

  • Если даже это не помогло, оставим только те interpretations, у которых лемма совпадает с той, которую дает natasha-spacy (в определении леммы spacy нередко ошибается).

  • Возвращаем просто сырой токен без ударения, если даже после этого у нас не образовалось единственного варианта.

Результаты

Код и данные: https://github.com/einhornus/russian_accentuation

Замечание: алгоритм не делает ёфикацию, поэтому текст следует предварительно пропустить через ёфикатор.

Литературный текст

Жил на опу́шке дрему́чего ле́са бе́дный дровосе́к со свое́й жено́й и двумя́ детьми́; ма́льчика зва́ли Гензель, а де́вочку — Гретель. Жил дровосе́к впроголодь; вот наступи́ла одна́жды в той земле́ така́я дорогови́зна, что не на что бы́ло ему́ купи́ть да́же хлеба на пропита́ние. И вот, под ве́чер, лёжа в посте́ли, стал он разду́мывать, и всё одолева́ли его́ ра́зные мы́сли и забо́ты; повздыхал он и говори́т жене́:

— Что же тепе́рь бу́дет с на́ми? Как нам прокорми́ть бе́дных дете́й, нам-то ведь и сами́м есть не́чего!

— А зна́ешь что, — отвеча́ла жена́, — дава́й-ка пораньше у́тром, то́лько начнёт света́ть, заведём дете́й в лес, в са́мую глуху́ю ча́щу; разведём им костёр, дади́м ка́ждому по куску́ хлеба, а са́ми уйдём на рабо́ту и оста́вим их одни́х. Доро́ги домо́й они́ не найду́т, вот мы от них и изба́вимся.

Ошибок нет. Однако, слова «впроголодь», «повздыхал» и «пораньше» не были найдены в словаре, и для них ударения не проставлены. Кроме того, не поставлено ударение в слове «хлеба»: в словаре не записано, что «хлеба́» — множественное число, а «хле́ба» — родительный падеж (парсер немного запутался из-за того, что нормальная форма множественного числа — «хлебы»). Проблемы подобного рода со словарем есть, но они довольно редки.

Некоторые сложные случаи

Я стою у окна́. В до́ме больши́е о́кна.

Ну, «стоить у окна» в теории тоже возможно. Слово «большие» иногда получает ударение на и (работа spacy стохастическая), иногда остается без него.

Я куплю́ немно́жко земли́. Не смей претендова́ть на мои́ зе́мли.

Без ошибок

Нам нужны́ учителя́. Я процити́ровал своего́ учи́теля.

Без ошибок

Мы зале́зли на строи́тельные леса́.

Без ошибок

Самолёт жда́ли два дире́ктора. Дире́ктора бы́ли пожило́го во́зраста.

Увы, ошибка natasha-spacy со словом «директора»

Ключ снача́ла находи́лся в двери, а пото́м лежа́л на полу́.

Не разрешена неоднозначность со словом «двери»

Солда́ты вы́строились в ка́ре.

К сожалению, слово «каре» не нашлось в словаре.

По-мо́ему, Маше стоит купи́ть маши́ну.

Не разрешена неоднозначность со словом «стоит»

По ле́су броди́л медве́дь. Мы наткну́лись на него́, когда́ гуля́ли в лесу́.

Без ошибок.

Читайте так же:

  • В России игровые платформы могут приравнять к соцсетямВ России игровые платформы могут приравнять к соцсетям Подведомственный Роскомнадзору Главный радиочастотный центр (ГРЧЦ) предложил ввести законодательное регулирование рынка гейминга и киберспорта в России. Для предотвращения потенциальных угроз безопасности. В частности. Предлагается распространить на индустрию требования «закона Яровой» […]
  • Второй экран Xiaomi Mi 11 Ultra становится ещё полезнее для съёмкиВторой экран Xiaomi Mi 11 Ultra становится ещё полезнее для съёмки Компания Xiaomi представила в конце марта 2021 года необычный флагманский смартфон — Xiaomi Mi 11 Ultra. Его «визитной карточкой» стал миниатюрный второй экран. Встроенный в блок камеры на задней панели.  Вспомогательный экран, например. Можно использовать в качестве […]
  • Что такое наполнение сайтаЧто такое наполнение сайта Написание отличного контента-это выбор. Вы можете выбрать время и работу, необходимые для создания отличного контента и создания процветающего бренда. Или вы можете выбрать легкий путь и написать плохой контент – путь, который в конечном итоге никуда вас не приведет. Это приведет лишь […]
  • Яндекс ведет переговоры о покупке «Азбуки вкуса»Яндекс ведет переговоры о покупке «Азбуки вкуса» Компания Яндекс ведет переговоры о покупке сети супермаркетов «Азбука Вкуса». Об этом рассказали The Bell два источника на розничном рынке. Смысл сделки заключается в синергии «Азбуки Вкуса» с сервисом быстрой доставки еды Яндекс.Лавка. Обе компании ориентируются на премиальную […]