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

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

Мой опыт

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

Идея

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

Данные

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

WiktionaryWiktionary

Для парсинга 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 со словом «директора»

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

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

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

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

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

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

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

Без ошибок.

© Habrahabr.ru