Время прочтения: 5 мин.

Всем привет! Сегодня я хочу поделиться решением задачи по анализу жалоб граждан в Московскую мэрию, которую я реализовывал в рамках конкурса DSC.

Портал mos.ru агрегирует жалобы граждан на общественно значимые проблемы Москвы, находящиеся в зоне ответственности местных властей. На данный момент в открытом доступе находятся около 100 тысяч сообщений. Каждому сообщению автором или модератором присваивается одна из немногочисленных категорий, детально проблема излагается в тексте обращения. Как правило, темы сообщения специфичны, и категория не описывает суть проблемы. Требовалось и проанализировать жалобы, опредилив их в соответствующие подкатегории.

Для решения данной задачи я применил gensim для тематического моделирования и визуализировал данные с помощью дендрограмм. Уточню — scikit-leam не поддерживает метод латентного размещения Дирихле. Поэтому я и воспользовался пакетом geпsim, разработанным Радимом Ржехоржеком – исследователем в области машинного обучения и консультантом из Великобритании. Получилось эффектно, понятно и, по итогам конкурса, данное  решение было удостоено упоминания как «интересная реализация»

Этапы реализации задачи по большей части стандартные:

  1. Загрузка данных;
  2. Нормализация текста;
  3. Векторизация TF-IDF;
  4. Классификация.

Для начала необходимо сделать импорт необходимых библиотек.

from itertools import combinations
import pymorphy2
from gensim import models, corpora  
from nltk import corpus, download as download_sw
import networkx as nx
download_sw('stopwords') # загружаем стоп-слова
stopwords = corpus.stopwords.words('russian')

Загрузка данных

Код стандартный. Объем датасета – 90176 строк, выглядит так:


themetextresponsiblepost date
0‘Сообщение о проблеме «Несоблюдение требований…Выше второго этажаЛарин А.С.начальник Объединения административно-техниче…16 Ноября 2020 в 16:31′
1‘Сообщение о проблеме «Неубранная городская те…По адресу Снежная д24 расположена музыкальная …Кучма А.А.глава управы района Свиблово города Москвы05 Мая 2020 в 15:45′

Далее, немного почистил датасет, удалив дубликаты, и обогатил некоторыми атрибутами, спарсенными с текстового поля самого обращения с помощью метода pd.DataFrame.assign, выделив адрес и район обращения. Результат – ниже:

 problemadressareatext
1Неубранная городская территорияСнежная улица д.24ДСвибловоПо адресу Снежная д24 расположена музыкальная …
3Неубранная городская территорияСнежная улица д.24ДСвибловоОчистите опору освещения. Приведите в надлежащ…
4Захламление территорииСнежная улица д.1АСвибловоБолее двух недель лежит куча грунта в перемешк…

Морфология и токенизация

from nltk.stem.snowball import SnowballStemmer
stemmer = SnowballStemmer("russian")
def token_morf_stemm(text):
    morph = pymorphy2.MorphAnalyzer()
    tokens = (text.str.lower().replace('[^а-я-ё]|-', ' ', regex=True)
                          .apply(lambda x: [morph.parse(word)[0].normal_form for word in x.split(' ')
 if len(word) > 2 and word not in stopwords]))
    stemms=[]
    for tokk in tokens:
        stem = [stemmer.stem(t) for t in tokk]
        stemms.append(stem)
    return stemms      

# готовим данные  токениация, морфология
tokens = token_morf_stemm(prepared['text'])
len(tokens)

Векторизация

Для расчета TF-IDF я использовал класс TfidfVectorizer из библиотеки sklearn, чтобы вычислить TF-IDF. Отмечу, что просто подсчет количества слов в каждом документе имеет одну проблему: это дает больший вес более длинным документам, чем более коротким документам. Чтобы избежать этого, мы можем использовать частоту (TF — термин частоты) т.е. # количество (слово) / # общее количество слов в каждом документе. В библиотеке Scikit-Learn есть преобразователь TfidfVectorizer, в модуле feature_extraction.text, предназначенный для векторизации документов с оценками TF–IDF. Технически TfidfVectorizer вызывает преобразователь CountVectorizer, который мы использовали для превращения мешка слов в количества вхождений лексем, а затем TfidfTransformer, нормализующий эти количества обратной частотой документа. На входе TfidfVectorizer принимает последовательность имен файлов, объектов файлов или строк с коллекцией исходных документов (мы будем использовать prepared[‘text’]), подобно преобразователю CountVectorizer. Далее применяются методы по умолчанию лексемизации и предварительной обработки, если не указаны другие функции. Результатом работы преобразователя является разреженная матрица вида ((doc, term), tfidf), где каждый ключ является парой «документ/лексема», а значением служит оценка TF–IDF.

tfidf_vectorizer=TfidfVectorizer(ngram_range=(1,3), max_df=0.8, max_features=10000, min_df=0.01, stop_words=stopwords, use_idf=True, 
                                 tokenizer=casual_tokenize)
tfidf_matrix = tfidf_vectorizer.fit_transform(prepared['text']).toarray()

Теперь у нас есть пул из 88283 сообщений и построенный по ним 223-мерный вектор признаков.

 KMeansMiniBatchKMeansproblemtexttokens
006Несоблюдение требований к размещению информаци…Выше второго этажа[выш, втор, этаж]
125Неубранная городская территорияПо адресу Снежная д24 расположена музыкальная …[адрес, снежн, располож, музыкальн, школ, задн…
207Неубранная городская территорияПосле проведения работ на кабельной канализаци…[проведен, работ, кабельн, канализац, мгтс, за…
325Неубранная городская территорияОчистите опору освещения. Приведите в надлежащ…[очист, опор, освещен, привест, надлежа, состоян]
406Захламление территорииБолее двух недель лежит куча грунта в перемешк…[два, недел, лежа, куч, грунт, перемешок, мусор]

Визуализация

Для визуализации сначала решил построить график с помощью IncrementalPCA модуля sklearn.decomposition, однако картинка не дала интерпретируемых результатов:

Тогда я решил использовать dendrogram пакета scipy.cluster.hierarchy. Картинка получилась понятнее.

link_matrix=ward(tfidf_matrix[:1000])
fig, ax=plt.subplots(figsize=(15, 20))
ax=dendrogram(link_matrix, orientation="right", labels=sent_topics[:1000])
plt.tick_params(axis='x', which='both', bottom='off', top='off', labelbottom='off')
plt.tight_layout()

Тематическое моделирование

Далее переходим непосредственно к моделированию  — готовим данные (корпус) для LDA модели.

distcorp = corpora.Dictionary(tokens)
corpus = [distcorp.doc2bow(text) for text in tokens]

и собираем ее:

lda_model = models.ldamodel.LdaModel(corpus=corpus, 
                                     num_topics=10, 
                                     id2word=distcorp,
                                     random_state=5,
                                     update_every=1,
                                     #chunksize=10
                                     passes=10,
                                     #alpha='symmetric',
                                     iterations=10,
                                     per_word_topics=True)

Готово. Посмотрим первые 10 слов по первым темам:

lda_model.show_topics(num_topics=3, num_words=10, formatted=True)
[(0,  '0.110*"мусор" + 0.077*"территор" + 0.052*"убра" + 0.025*"строительн" + 0.022*"газон" + 0.017*"куч" + 0.016*"убира" + 0.015*"захламлен" + 0.015*"бетон" + 0.014*"свалк"'),
 (1,  '0.105*"провер" + 0.102*"закон" + 0.089*"рекламн" + 0.089*"реклам" + 0.087*"прос" + 0.063*"конструкц" + 0.055*"размещен" + 0.032*"столб" + 0.029*"щит" + 0.023*"установк"'),
 (2,  '0.115*"дом" + 0.062*"улиц" + 0.046*"здан" + 0.042*"адрес" + 0.034*"сторон" + 0.025*"штендер" + 0.024*"фасад" + 0.023*"напрот" + 0.022*"прос" + 0.019*"надп"')]

Также была задача построить облако слов. Я сделал это с помощью wordcloud.

from wordcloud import WordCloud, STOPWORDS
cloud=WordCloud(stopwords=stopwords, 
                background_color="white",
                width=2500,
                height=1800,
                max_words=10,
                colormap='tab10',
                color_func=lambda*args, **kwargs: cols[i], 
                prefer_horizontal=1.0)
topics_p=lda_model_mc.show_topics(formatted=False)
fig, axes = plt.subplots(5, 2, figsize=(10, 10), sharex=True, sharey=True)
for i, ax in enumerate(axes.flatten()):
    fig.add_subplot(ax)
    topic_words=dict(topics_p[i][1])
    cloud.generate_from_frequencies(topic_words, max_font_size=300)
    plt.gca().imshow(cloud)
    plt.gca().set_title("Topic "+str(i), fontdict=dict(size=12))
    plt.gca().axis('off')

Так, благодаря данному алгоритму, удалось оценить полноту проблемы, разделить жалобы на подтемы с помощью методов ML, оценить их значимость, а также визуализировать результат работы.