Хабрарейтинг: построение облака русскоязычных слов на примере заголовков Хабра

Привет Хабр.

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

Разберемся, как строить такую картинку:
8ccta6jikwu2sfopuhf7fdpetgu.png
Также посмотрим облако статей Хабра за все годы.

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

Парсинг


Исходный датасет, как и в предыдущем случае, это 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()


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

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

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


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

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

Заключение


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

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

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

© Habrahabr.ru