Читать нас в Telegram
Иллюстратор: Женя Родикова

Предположим, у вас большая коллекция текстов (как минимум несколько сотен, и вы хотите узнать, о чем они — и не просто узнать, а сопоставить с чем-то, что вам уже об этих текстах известно, например, с их жанром. Как же определить содержание автоматически?

Один из популярных способов решения такой проблемы — тематическое моделирование. Оно позволяет выделять из текстов темы, связанные с определенными множествами слов, и затем смотреть, с какой вероятностью тексты соотносятся с этими темами. Для нашей задачи мы будем применять тематическое моделирование, основанное на Латентном размещении Дирихле (LDA) (подробнее прочитать про это можно здесь). Так как нужные алгоритмы уже имплементированы в Питоне, математика, стоящая за ними, нас беспокоить не будет.

Предобработка текстов 

Для начала нам нужен датасет с текстами в табличном формате. Одна из колонок должна содержать текст, в других могут быть метаданные. Каждая строка должна соответствовать одному тексту. Установим нужные библиотеки: нам нужна библиотека pandas, которая позволяет работать с данными в табличном формате (такую структуру данных часто называют датафреймом). После того, как мы установили pandas, давайте прочитаем наш датасет с помощью метода read_csv (если таблица в формате csv) и сохраним наш датафрейм в переменную data. 

import pandas as pd
data = pd.read_csv(path_to_dataset)

После того, как мы прочитали данные, посмотреть на верхние пять строк можно с помощью  метода head:

data.head()

Теперь нам нужна функция для токенизации (разбиения на слова), список знаков препинания и стоп-слова. Стоп-слова — это частые слова, которые встречаются почти во всех текстах и не несут содержательной информации о нем, поэтому их вместе со знаками пунктуации лучше всего удалить. Для этого нам и нужны их списки. Загрузить функцию-токенизатор можно из библиотеки nltk. Для того, чтобы она работала, нам также понадобится пакет с токенизатором, который разбивает текст на предложения. Его можно загрузить с помощью nltk_download:

from nltk.tokenize import word_tokenize 
from nltk import download as nltk_download 
 
nltk_download("punkt")

Стоп-слова можно также загрузить из библиотеки nltk. Мы возьмем их из открытого репозитория программы по DH ВШЭ (репозиторий можно найти по этой ссылке), но так как точного списка стоп-слов нет, можно брать их из других мест. В конце все то, что мы будем выкидывать из текста (стоп-слова и знаки препинания), должно быть сохранено в переменную в виде листа.

!wget https://raw.githubusercontent.com/dhhse/dh2020/master/data/stop_ru.txt
with open ("stop_ru.txt", "r") as stop_ru:
    rus_stops = [word.strip() for word in stop_ru.readlines()]
punctuation = '!\"#$%&\'()*+,-./:;<=>?@[\]^_`{|}~—»«...–'    
filter = rus_stops + list (punctuation)

После того, как мы разобрались со стоп-словами, давайте установим лемматизатор (лемматизация — приведение слов к словарной форме). Для русского языка есть прекрасная библиотека pymorphy2. Ее мы и будем использовать.

from pymorphy2 import MorphAnalyzer
 
parser = MorphAnalyzer()

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

  1. Приводим текст к нижнему регистру;
  2. Токенизируем его;
  3. Выбрасываем слова, которые входят в наш список, объединяющий знаки препинания и стоп-слова;
  4. Очищенный токенизированный текст мы лемматизируем — это как раз то, что нужно для тематического моделирования.
def preprocess(input_text):
    """
    Функция для предобработки текста. Слова приводятся к нижнему регистру,
    стоп-слова удаляются, далее слова лемматизируются
    :param input_text: Входной текст для очистки и лемматизации
    :return: Очищенный и лемматизированный текст
    """
    text = input_text.lower()
    tokenized_text = word_tokenize(text)
    clean_text = [word for word in tokenized_text if word not in filter]
    lemmatized_text = [parser.parse(word)[0].normal_form for word in 
                       clean_text]
    
    return lemmatized_text

После написания функции нам нужно применить ее ко всем нашим текстам. Чтобы это сделать, можно воспользоваться функцией map. Она применит нашу функцию к каждой строке выбранной нами колонки с текстом. Результат мы запишем в новую колонку:

data["text_processed"] = data["text"].map(preprocess)

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

Далее нужно импортировать библиотеку gensim. Это популярная открытая библиотека для тематического моделирования, в которой есть нужная нам модель — LDA. Затем мы должны создать словарь для тематического моделирования из лемматизированного текста. После создания словаря лучше всего отфильтровать те слова, которые встречаются в слишком большом количестве текстов, и те, которые встречаются слишком редко. Для этого есть метод filter_extremes, который принимает в себя аргументы no_above (только слова, которые встречаются не более чем в указанной доле текстов) и no_below (слова, которые встречаются не менее чем в указанном количестве текстов). После удаления лишних слов словарь лучше всего ужать в размерах, убрав пропуски с помощью метода compactify.

import gensim
 
gensim_dictionary = gensim.corpora.Dictionary(data["text_processed"])
gensim_dictionary.filter_extremes(no_above=0.1, no_below=20)
gensim_dictionary.compactify()

Создадим корпус в виде «мешка слов» (bag of words):

corpus = [gensim_dictionary.doc2bow(text) 
          for text in data['text_processed']]

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

lda_20 = gensim.models.LdaMulticore(corpus, 
                                 num_topics=20, 
                                 id2word=gensim_dictionary, 
                                 passes=10, random_state=6457)

С помощью метода print_topics можно посмотреть, какие топики в итоге получились:

lda_20.print_topics()

Сколько выделять топиков?

При создании тематического моделирования всегда возникает проблема с количеством топиков — как лучше определить их оптимальное число? Один из подходов заключается в том, чтобы сделать несколько моделей на разное количество топиков и затем посмотреть, насколько топики получаются осмысленными. Проблема этого способа в том, что его трудно формализовать. Можно поступить с данной проблемой по-другому, используя формальные метрики. Для этого можно использовать метрики, встроенные в библиотеку gensim — например, c_v или c_uci (подробнее про них, а также про другие метрики тематического моделирования можно прочитать здесь). Посмотрим, какое значение c_v есть дя модели на двадцать тем:

from gensim.models import CoherenceModel
coherence_model_lda = CoherenceModel(model=lda_20,
                                     texts=data["text_processed"],
                                     dictionary=gensim_dictionary,
                                     coherence="c_v")
coherence_lda = coherence_model_lda.get_coherence()
 
print("\nCoherence Score: ", coherence_lda)

Значение метрики важно не только само по себе, но и как сравнение с другими возможными количествами тем. Чтобы не делать множество моделей самому, а потом сравнивать их метрики, создадим график, где на оси х будет отложено количество тем, а на оси у — соответствующее значение метрики. Для этого сначала надо сделать список значений нужной метрики для какого-нибудь промежутка тем (для экономии времени лучше не вычислять их для каждого значения количества тем, вполне можно взять шаг, например в три темы — но это зависит от того, сколько тем вы ожидаете в ваших текстах). Для этого лучше всего написать функцию, которая будет на вход принимать наш словарь, корпус, тексты, значения start (количество топиков от которых начинается подсчет) и step (шаг), max (максимальное число топиков) а также measure — метрику, которая будет использоваться (по умолчанию стоит “c_uci”)

def coherence_score(dictionary, corpus, texts, max, start=2, step=3,
                    measure="c_uci"):
    """
    Функция вычисляет метрики для оценки тем. моделирования и выводит 
    график, где по оси x отложено количество топиков, а по оси y — значение 
    метрики
    :param dictionary: словарь для тематического моделирования
    :param corpus: корпус в виде мешка слов
    :param texts: тексты документов
    :param max: максимальное количество топиков
    :param start: стартовое количество топиков
    :param step: промежуток, с которым вычисляются топики
    :param measure: метрика
    """
    coherence_values = []
    for num_topics in range(start, max, step):
        model = gensim.models.LdaMulticore(corpus=corpus, id2word=dictionary, 
                                           passes=10, num_topics=num_topics, 
                                           random_state=6457)
        coherencemodel = CoherenceModel(model=model, texts=texts, 
                                        dictionary=dictionary, 
                                        coherence=measure)
        coherence_values.append(coherencemodel.get_coherence())
    x = range(start, max, step)
    plt.plot(x, coherence_values)
    plt.xlabel("Number of Topics")
    plt.ylabel(measure + "score")
    plt.legend(("coherence_score"), loc='best')
    plt.show() 

Перед тем, как вызывать функцию, нужно импортировать библиотеку matplotlib:

import matplotlib.pyplot as plt
 
coherence_score(dictionary=gensim_dictionary, corpus=corpus, texts=data["text_processed"], start=2, max=30, step=3, measure="c_v")

Давайте для удобства предположим, что, основываясь на метриках, мы решили выбрать модель на двадцать топиков. В библиотеке gensim есть встроенная интерактивная визуализация расстояния между темами. Можно использовать ее, чтобы оценить, насколько пересекаются между собой полученные топики.

import pyLDAvis.gensim_models as gensimvis
import pyLDAvis
 
vis_20 = gensimvis.prepare(lda_20, corpus, gensim_dictionary)
pyLDAvis.enable_notebook()
 
vis_20

Что делать с результатами  тематического моделирования дальше?

Как же дальше использовать результаты полученного тематического моделирования? Для начала можно назначить каждому документу наиболее подходящий (наиболее вероятный) для него топик. Для этого документ сначала нужно представить в виде «мешка слов», а затем использовать метод нашей модели get_document_topics. После этого следует выбрать топик с наибольшей вероятностью — поскольку именно он нас интересует.

def get_topic(words, lda):
    """
    Функция назначает документу наиболее вероятный топик
    :param words: лемматизированный текст документа
    :param lda: тематическая модель
    :return: список из наиболее вероятного топика 
    и его вероятности
    """
    bag = lda.id2word.doc2bow(words)
    topics = lda.get_document_topics(bag)
    topic_dictionary = {}
    for topic in topics:
        topic_dictionary[topic[1]] = str((topic[0])) 
    main_probability = max(topic_dictionary)
    main_topic = topic_dictionary[main_probability]
    return [main_topic, main_probability]

Теперь можно применить нашу функцию к датасету. Это можно сделать с помощью функции apply:

data["lda_20"] = data["text_processed"].apply(get_topic, lda=lda_20)

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

data["topic_20"] = data["lda_20"].str[0]
data["probability_20"] = data["lda_20"].str[1]
del data["lda_20"]

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

import seaborn as sns
 
sns.catplot(x="genre", y="topic_20", kind="swarm", data=data, height=5, aspect=3)

Важно помнить, что тексты и метаданные  могут быть самые разные. Посмотреть на тематическое моделирование для фанфиков можно по ссылке (тетрадка в гугл-колабе).