Трагедия гуманитария в трех актах.

Важный дисклеймер: все, написанное в статье ниже - это исключительно нубский взгляд на решение проблемы. Но конечным результатом я вполне довольна, поэтому и выношу его на публичный суд.

Так сложились обстоятельства, что передо мной встала задача создать базу данных словаря-тезауруса для дальнейшего использования в лингвистических исследованиях. Собственно, лингвистика - это и есть моя специальность, к IT я имею весьма опосредованное отношение в виде одного (незаконченного) онлайн-курса по основам Python и одного семестра программирования в универе (на который я почти не ходила).

На руках у меня была электронная версия сверстанного для печати словаря в формате .pdf, текст которого после извлечения выглядел примерно вот так:

Небольшой фрагмент для сравнения

Ажиота́ж, перен.
Ажиота́жный
Ажита́ция, устар.
Аза́рт
Аффе́кт
Безу́мство, перен.
Беспа́мятство
Беспоко́йно
Беспоко́йный
Беспоко́йство
Беспоко́иться
Беспоря́дки

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

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

Итак, какие способы я опробовала:

  1. Замена гласных букв с диакритикой на обычные при помощи метода replace()

  2. То же самое, но с использованием метода translate()

  3. Удаление диакритики с использованием модуля unicodedata

Метод replace()

Это, наверное, один из первых (если не самый первый) метод, который изучают при знакомстве со строками в Python. Так что само собой разумеется, что первым делом я подумала про него.

Опробуем его на каком-нибудь одном слове:

s = "Беспа́мятство"
new = s.replace('а́', 'а')
print(new)

В результате получаем:

Беспамятство

Работает! Вот только появляется одна очевидная проблема. Гласных букв в русском алфавите всего 10: а, я, у, ю, о, е, ё, э, и, ы. Букву ё можно пока выкинуть, потому что над ней ударение не ставится, и тогда остается 9.

Но знак ударения у нас может быть и над первой буквой в слове, так что к строчным вариантам букв добавляются прописные - теперь их всего 17 (букву ы можно не учитывать, потому что в этом словаре нет слов, которые бы с нее начинались). Итого - 17 вариантов замен и 34 буквы в целом (набор с диакритикой и без). Конечно, можно было бы весь текст привести к строчному написанию, но я этого делать пока не хочу в силу других причин.

В общем, решение какое-то громоздкое получается. И на практике я от него сразу отказалась. Но вот сейчас решила развлечения ради посмотреть, как это будет выглядеть:

#Сначала заведем списки букв с ударением и без
no_accent = ['а', 'я', 'у', 'ю', 'о', 'е', 'э', 'и', 'ы', 'А', 'Я', 'У', 'Ю', 'О', 'Е', 'Э', 'И']
accented = ['а́', 'я́', 'у́', 'ю́', 'о́', 'е́', 'э́', 'и́', 'ы́', 'А́', 'Я́', 'У́', 'Ю́', 'О́', 'Е́', 'Э́', 'И́']

#Вручную прописывать каждую замену - долго и муторно
#Поэтому определим новую функцию
def replace_characters(original, new, str):
    for i in range(len(original) - 1):
        str = str.replace(original[i], new[i])
    return str

#Возьмем в качестве примера строку подлиннее  
str = "Беспа́мятство\nБеспоко́йно\nБеспоко́йный\nБеспоко́йство\nБеспоко́иться\nБеспоря́дки\nА́ховый"
res = replace_characters(accented, no_accent, str)
print(res)

Результат:

Беспамятство
Беспокойно
Беспокойный
Беспокойство
Беспокоиться
Беспорядки
Аховый

В принципе, все работает, на этом можно было бы остановиться. Но как-то не понравилась мне идея вручную задавать списки вариантов для замены.

Метод translate()

На самом деле в эту сторону я во время решения задачи даже не смотрела по той же самой причине, по которой я не стала в итоге использовать метод replace(). Но давайте проверим, как оно будет работать. В конце концов, список букв я уже задала выше, так что код напишется очень быстро.

no_accent = ['а', 'я', 'у', 'ю', 'о', 'е', 'э', 'и', 'ы', 'А', 'Я', 'У', 'Ю', 'О', 'Е', 'Э', 'И']
accented = ['а́', 'я́', 'у́', 'ю́', 'о́', 'е́', 'э́', 'и́', 'ы́', 'А́', 'Я́', 'У́', 'Ю́', 'О́', 'Е́', 'Э́', 'И́']

#Зададим таблицу перевода при помощи функции zip()
trans_table = dict(zip(accented, no_accent))

str = "Беспа́мятство\nБеспоко́йно\nБеспоко́йный\nБеспоко́йство\nБеспоко́иться\nБеспоря́дки\nА́ховый"

res = str.translate(trans_table)
print(res)

Выглядит лаконичнее, но результат...

Беспа́мятство
Беспоко́йно
Беспоко́йный
Беспоко́йство
Беспоко́иться
Беспоря́дки
А́ховый

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

Модуль unicodedata

А вот это решение я в итоге и использовала в своем финальном варианте кода. Разочаровавшись в методе replace(), я пошла бороздить просторы интернета в поисках более изящного кода и наткнулась на подходящее обсуждение на Stack Overflow.

Правда, речь там шла про удаление диакритики из текста на английском языке, что, как оказалось, не на 100% работает с русским. Но об этом позже.

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

import unicodedata

def remove_accents(input_str):
    nfkd_form = unicodedata.normalize('NFKD', input_str)
    return u"".join([c for c in nfkd_form if not unicodedata.combining(c)])

И я решила опробовать его на своем материале:

import unicodedata

def remove_accents(input_str):
    nfkd_form = unicodedata.normalize('NFKD', input_str)
    return u"".join([c for c in nfkd_form if not unicodedata.combining(c)])

str = "Беспа́мятство\nБеспоко́йно\nБеспоко́йный\nБеспоко́йство\nБеспоко́иться\nБеспоря́дки\nА́ховый"
res = remove_accents(str)
print(res)

Результат:

Беспамятство
Беспокоино
Беспокоиныи
Беспокоиство
Беспокоиться
Беспорядки
Аховыи

Кажется, что-то пошло немного не так...

Проблема в том, что эта функция полностью убирает диакритические знаки. А в русском языке есть две буквы с диакритикой - это многострадальная Ё и Й. И если буквой Ё еще можно пожертвовать (хотя конкретно здесь - нельзя, потому что в словаре она используется для различения произносительных вариантов слов), то букву Й терять никак нельзя.

Кто-нибудь другой, наверное, закопался бы глубже и подкорректировал функцию таким образом, чтобы она убирала только знак ударения, а другую диакритику не трогала. Но я все-таки нубка, и решения у меня нубские.

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

def replace_characters(original, new, str):
    for i in range(len(original) - 1):
        str = str.replace(original[i], new[i])
    return str

#буквы, которые нужно сохранить
char_preserve = ["й", "ё", "Ё"]
#знаки, на которые мы будем их менять
placeholders = ["@", "#", "%"]

str = "Беспа́мятство\nБеспоко́йно\nБеспоко́йный\nБеспоко́йство\nБеспоко́иться\nБеспоря́дки\nА́ховый"
temp = replace_characters(char_preserve, placeholders, str)
print(temp)

В результате получаем такую красоту:

Беспа́мятство
Беспоко́@но
Беспоко́@ны@
Беспоко́@ство
Беспоко́иться
Беспоря́дки
А́ховы@

Теперь осталось только убрать диакритику при помощи уже заданной функции remove_accents и произвести обратную замену символов на буквы:

temp = remove_accents(temp)
res = replace_characters(placeholders, char_preserve, temp)
print(res)

Конечный результат:

Беспамятство
Беспокойно
Беспокойный
Беспокойство
Беспокоиться
Беспорядки
Аховый

В общем, как уже говорилось выше, в конечном итоге я воспользовалась последним способом. Все-таки здесь мне пришлось вручную прописывать 3 замены, что в разы меньше, чем 17 в случае с методом replace(). Ну а метод translate(), по всей видимости, останется примером того, как делать не надо.

Комментарии (17)


  1. Irval
    00.00.0000 00:00
    +5

    В чем, собственно, проблема просто удалить все вхождения "символа ударения"?

    s = "Беспа́мятство\nБеспоко́йно\nБеспоко́йный\nБеспоко́йство\nБеспоко́иться\nБеспоря́дки\nА́ховый"
    s_clear = u"".join([c for c in s if ord(c) != 769])

    UPD: Мы же не в Индии живем, где за количество строк платят :)


    1. DistortNeo
      00.00.0000 00:00
      +4

      Юникод — сложная штука. Буква "á" может быть представлена как одним кодпоинтом (225), так и двумя (97, 769) или (1072, 769).


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


      1. Rsa97
        00.00.0000 00:00
        +1

        225 — это латинская a с ударением. Для кириллической а отдельного кодпоинта с ударением нет.
        Если же предполагать, что в тексте смешана кириллица с латиницей, то чистить надо гораздо серьёзнее, заменяя символы одинакового начертания a-а, e-е, o-о, p-р и так далее.


        1. aborouhin
          00.00.0000 00:00
          +1

          По уму, конечно, всё так. А по факту приходится приспосабливаться к тому печальному факту, что куча софта с надстрочными знаками юникода нормально работать до сих пор не умеет. Вот даже в Win11 наисвежайшая версия "блокнота" с модными вкладками и тёмной темой периодически отображает их с глюками (ударение съезжает вправо), а при перемещении курсора / выделении текста стрелками считает за 2 символа.

          Поэтому, чтобы минимизировать проблемы, сам тоже ударные á, ó, ý и é пишу а латинских версиях, и только и́, ы́, э́, ю́ и я́, для которых латинских аналогов нет, - в комбинированном виде (у меня на них все хоткеи настроены, т.к. имею дурную привычку во всех случаях, когда из-за разного ударения может меняться смысл слова, его явно проставлять).


      1. arokettu
        00.00.0000 00:00
        +2

        Я бы тогда предложил заюзать смесь способа @Irval c юникодом, что-то вроде

        def remove_accents(input_str):
            nfkd_form = unicodedata.normalize('NFKD', input_str)
            return u"".join([c for c in nfkd_form if ord(c) != 769])

        Сначала нормализуем в конкретное представление, потом удаляем конкретный символ


        1. DistortNeo
          00.00.0000 00:00

          Тогда мы ещё и букву "й" покорёжим.


          1. arokettu
            00.00.0000 00:00
            +1

            й и ё распадутся, как и у автора, на букву и модификатор, можно их собрать обратно нормализовав еще раз в NFKC

            def remove_accents(input_str):
                nfkd_form = unicodedata.normalize('NFKD', input_str)
                cleaned = u"".join([c for c in nfkd_form if ord(c) != 769])
                return unicodedata.normalize('NFKC', cleaned)


            1. DistortNeo
              00.00.0000 00:00

              Ок, принимается


    1. CrazyElf
      00.00.0000 00:00

      Да, похоже в данном случае это вообще единственный нормальный вариант )


    1. MountainGoat
      00.00.0000 00:00
      +1

      В форточку влетает зануда
      Зануда: Кадратные с-з-з-з-кобки не нуж-ж-ж-ны, не нуж-ж-ж-ны! Лишний масс-с-ифф! Лиш-ш-ш-шний!
      Зануда делает круг и вылетает обратно


      1. andreymal
        00.00.0000 00:00
        +4

        Без кадратных с-з-з-з-кобок на 15% медленнее


  1. user18383
    00.00.0000 00:00
    +1

    Метод translate не работает потому что нужно перед этим использовать maketrans


  1. Markscheider
    00.00.0000 00:00

    А это разовая задача или вам часто надо будет словари таким образом чистить?


  1. huder
    00.00.0000 00:00
    +4

    Честно говоря, первый способ выглядит лучше – да там список замен, но он читается однозначно и не сломает ничего, если по какой-то причине в словах будут @#% или просто другие диакритические буквы. А особенно если задача была сделать один раз - то на этом можно было и останавливаться


  1. VWVWV
    00.00.0000 00:00

    Я просто вставил Ваш текст в блокнот windows и сохранил в кодировке по умолчанию, вот что получилось: Беспоко?йство Беспоко?иться Беспоря?дки.
    Вопросительные знаки легко удалить можно заменой в том же блокноте.


  1. root4joy
    00.00.0000 00:00

    был у меня файл на мегабайт в юникоде где всего в одном месте было ударение но оно меняло смысл мегабайта - не смог заменить апострофом или заглавной буквой чтобы уменьшить фaйл в два раза ... программно убедился что только в одном месте файла ударение - стóит


  1. hard_sign
    00.00.0000 00:00

    ...а можно было просто воспользоваться утилитой unaccent