Привет, Хабр.

В последней части Хабрарейтинга был опубликован метод построения облака слов для англоязычных терминов. Разумеется, задача парсинга русских слов является гораздо более сложной, но как подсказали в комментариях, для этого существуют готовые библиотеки.

Разберемся, как строить такую картинку:



Также посмотрим облако статей Хабра за все годы.

Кому интересно, что получилось, прошу под кат.

Парсинг


Исходный датасет, как и в предыдущем случае, это csv с заголовками статей Хабра с 2006 до 2019 года. Если кому интересно попробовать самостоятельно, скачать его можно здесь.

Для начала, загрузим данные в Pandas Dataframe и сделаем выборку заголовков за требуемый год.

df = pd.read_csv(log_path, sep=',', encoding='utf-8', error_bad_lines=True, quotechar='"', comment='#')

if year != 0:
    dates = pd.to_datetime(df['datetime'], format='%Y-%m-%dT%H:%MZ')
    df['datetime'] = dates
    df = df[(df['datetime'] >= pd.Timestamp(datetime.date(year, 1, 1))) & (
            df['datetime'] < pd.Timestamp(datetime.date(year + 1, 1, 1)))]

# Remove some unicode symbols
def unicode2str(s):
    try:
        return s.replace(u'\u2014', u'-').replace(u'\u2013', u'-').replace(u'\u2026', u'...').replace(u'\xab', u"'").replace(u'\xbb', u"'")
    except:
        return s
titles = df["title"].map(unicode2str, na_action=None)

Функция unicode2str нужна для того, чтобы убрать из вывода консоли разные хитровывернутые юникодные символы, типа нестандартных кавычек — под OSX это работало и так, а при выводе в Windows Powershell выдавалась ошибка «UnicodeEncodeError: 'charmap' codec can't encode character». Разбираться с настройками Powershell было лень, так что такой способ оказался самым простым.

Следующим шагом необходимо отделить русскоязычные слова от всех прочих. Это довольно просто — переводим символы в кодировку ascii, и смотрим что остается. Если осталось больше 2х символов, то считаем слово «полноценным» (единственное исключение, которое приходит в голову — язык Go, впрочем, желающие могут добавить его самостоятельно).

def to_ascii(s):
    try:
        s = s.replace("'", '').replace("-", '').replace("|", '')
        return s.decode('utf-8').encode("ascii", errors="ignore").decode()
    except:
        return ''

def is_asciiword(s):
    ascii_word = to_ascii(s)
    return len(ascii_word) > 2

Следующая задача — это нормализация слова — чтобы вывести облако слов, каждое слово нужно вывести в одном падеже и склонении. Для английского языка мы просто убираем "'s" в конце, также убираем прочие нечитаемые символы типа скобок. Не уверен, что этот способ научно-правильный (да и я не лингвист), но для данной задачи его вполне достаточно.

def normal_eng(s):
    for sym in ("'s", '{', '}', "'", '"', '}', ';', '.', ',', '[', ']', '(', ')', '-', '/', '\\'):
        s = s.replace(sym, ' ')
    return s.lower().strip()

Теперь самое важное, ради чего все собственно и затевалось — парсинг русских слов. Как посоветовали в комментариях к предыдущей части, для Python это можно сделать с помощью библиотеки pymorphy2. Посмотрим, как она работает.

import pymorphy2

morph = pymorphy2.MorphAnalyzer()
res = morph.parse(u"миру")
for r in res:
    print r.normal_form, r.tag.case

Для данного примера имеем следующие результаты:

мир NOUN,inan,masc sing,datv datv
мир NOUN,inan,masc sing,loc2 loc2
миро NOUN,inan,neut sing,datv datv
мир NOUN,inan,masc sing,gen2 gen2

Для слова «миру» MorphAnalyzer определил «нормальную форму» как существительное (noun) «мир» (или «миро», впрочем, не знаю что это такое), единственное число (sing), и возможные падежи как dativ, genitiv или locative.

С использованием MorphAnalyzer парсинг получается довольно простым — убеждаемся, что слово является существительным, и выводим его нормальную форму.

morph = pymorphy2.MorphAnalyzer()

def normal_rus(w):
    res = morph.parse(w)
    for r in res:
        if 'NOUN' in r.tag:
            return r.normal_form
    return None

Осталось собрать все вместе, и посмотреть что получилось. Код выглядит примерно так (несущественные фрагменты убраны):

from collections import Counter

c_dict = Counter()
for s in titles.values:
    for w in s.split():
        if is_asciiword(w):
             # English word or digit
             n = normal_eng(w)
             c_dict[n] += 1
        else:
             # Russian word
             n = normal_rus(w)
             if n is not None:
                c_dict[n] += 1

На выходе имеем словарь из слов и их количеств вхождений. Выведем первые 100 и сформируем из них облако популярности слов:

common = c_dict.most_common(100)
wc = WordCloud(width=2600, height=2200, background_color="white", relative_scaling=1.0,
               collocations=False, min_font_size=10).generate_from_frequencies(dict(common))
plt.axis("off")
plt.figure(figsize=(9, 6))
plt.imshow(wc, interpolation="bilinear")
plt.title("%d" % year)
plt.xticks([])
plt.yticks([])
plt.tight_layout()
file_name = 'habr-words-%d.png' % year
plt.show()

Результат, впрочем, оказался весьма странным:



В текстовом виде это выглядело так:

   век 3958
   исполняющий 3619
   секунда 1828
   часть 840
   2018 496
   система 389
   год 375
   кандидат 375

Слова «исполняющий», «секунда» и «век» лидировали с огромным отрывом. И хотя, это в принципе, возможно (можно представить заголовок типа «Перебор паролей со скоростью 1000 раз в секунду займет век»), но все же было подозрительно, что этих слов так много. И не зря — как показала отладка, MorphAnalyzer определял слово «с» как «секунда», а слово «в» как «век». Т.е. в заголовке «С помощью технологии...» MorphAnalyzer выделял 3 слова — «секунда», «помощь», «технология», что очевидно, неверно. Следующими непонятными словами было «при» («При использовании ...») и «уже», которые определялись как существительное «пря» и «уж» соответственно. Решение было простым — учитывать при парсинге только слова длиннее 2х символов, и ввести список русскоязычных слов-исключений которые исключались бы из анализа. Опять же, возможно это не совсем научно (например статья про «наблюдение изменения раскраски на уже» выпала бы из анализа), но для данной задачи уже :) достаточно.

Окончательный результат более-менее похож на правду (за исключением Go и возможных статей про ужей). Осталось сохранить все это в gif (код генерации gif есть в предыдущей части), и мы получаем анимированный результат в виде популярности ключевых слов в заголовках Хабра с 2006 по 2019 год.



Заключение


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

Сама работа с кириллическими текстами в Python, кстати, далека от совершенства — мелкие проблемы с выводами символов в консоль, неработающий вывод массивов по print, необходимость дописывать u"" в строках для Python 2.7, и пр. Даже странно что в 21 веке, когда вроде отмерли все атавизмы типа KOI8-R или CP-1252, проблемы кодировки строк еще остаются актуальными.

Наконец, интересно отметить, что добавление русских слов в облако текста практически не увеличило информативности картинки по сравнению с англоязычной версией — практически все IT-термины и так являются англоязычными, так что список русских слов за 10 лет изменился гораздо менее значительно. Наверное, чтобы увидеть изменения в русском языке, надо подождать лет 50-100 — через указанное время будет повод обновить статью еще раз ;)

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


  1. Ktulhy
    05.03.2019 16:48

    А почему вы использовали 2.7, а не какой-нибудь свежий 3.7, в которых все строки юникодные по умолчанию?


    1. DmitrySpb79 Автор
      05.03.2019 16:50

      Стоило так сделать, да. Просто начал проект на 2.7, а переделывать было уже лень.


      1. Ktulhy
        06.03.2019 04:05

        Если лень передлывать руками есть утилита 2to3 которая решает 90% проблем перехода со второй версии на третью


      1. trapwalker
        06.03.2019 16:31
        +1

        Да что там переделывать-то?
        Однако даже в 2 питоне есть простое правило: на входе декодируем, на выходе кодируем, внутри только юникод.
        Проблема с символами, кстати, в данном случае на уровне Windows. Там до сих пор встречаются ситуации, когда командная строка и входные параметры интерпретируется как win866 (запрещенная в разумном мире однобайтовая кодировка), а терминал, куда пишет stdout, в win1251 (запрещенная в разумном мире однобайтовая кодировка).
        Можно задекорировать stdout в перекодирующий поток. Там, в общем-то, так и задумано из коробки, но для винды имеет смысл это сдедалть явно, поскольку не всегда можно получить от stdout его кодировку через атрибут. Ошибка возникает в двух случаях: 1) когда кодировка не определена и принимается как ascii, соответственно любые не ascii символы будут конвертироваться с ошибкой; 2) когда в строке попадаются символы, которым нет аналогов в однобайтной кодировке (длинные тире, мягкие переносы и всякое такое). Первый пункт у вас решен, второй вы решаете заменой некоторых символов. Я бы настроил дополнительно конвертацию с игнором или автозаменой непредставимых символов. И я бы всё же не работал внутри программы с кодированными (не юникодными) строками. Это плохая примета=)