Привет! Меня зовут Соня Асанина, я работаю в команде Центра технологий искусственного интеллекта Газпромбанка. В этой статье я расскажу, как тематическое моделирование и мягкая кластеризация помогают нам извлекать ценные инсайты из клиентских отзывов.
Каждый день мы получаем тысячи отзывов от клиентов. В каждом есть информация, которая помогает выявлять проблемы и дает понимание, как улучшать продукты и сервисы. Но часто очень сложно извлечь эти инсайты из огромного потока неструктурированных данных.
К примеру, мы получаем отзыв, в котором клиент недоволен кредитной картой и предлагает что-то изменить в приложении, но при этом выражает благодарность за вежливое обслуживание в отделении. К какой категории отнести отзыв? А если таких смешанных отзывов тысячи — как определить, какие продукты действительно требуют улучшения, а какие работают отлично?
Обрабатывать вручную такой поток сложно. А классические методы анализа часто не справляются с этой задачей, поскольку загоняют многогранные пользовательские отзывы в жесткие рамки одиночных категорий. Поэтому мы обратились к более гибким инструментам — тематическому моделированию и мягкой кластеризации.
Что такое тематическое моделирование и почему оно работает
Тематическое моделирование — это подход к анализу текстовых данных, который помогает выявить скрытые тематические структуры в коллекции документов. По сути, это мягкая кластеризация, которая не просто разбивает тексты на группы, а определяет, с какой вероятностью каждый текст относится к той или иной теме.
В отличие от жесткой кластеризации, где документ жестко закрепляется за одним кластером, в тематическом моделировании текст может одновременно с разной степенью вероятности принадлежать нескольким темам. Это гораздо ближе к тому, как мы, люди, воспринимаем тексты — ведь реальные отзывы клиентов редко касаются только одной темы.
Про данные
Мы тестировали различные подходы к тематическому моделированию на реальных отзывах о российских банках. Это короткие тексты (до 150 слов), в которых пользователи хвалят банк или, наоборот, возмущаются его какими-либо продуктами/услугами.
Данные собраны с сайта sravni.ru за 2023 год и I квартал 2024 года. Если интересно, датасет доступен по ссылке.
На первый взгляд задача кажется простой — каждый отзыв на платформе уже отнесен к определенной категории, но, как я уже писала выше, большинство из них затрагивают несколько тем одновременно. Поэтому мы решили подойти к задаче с точки зрения мультилейбл-кластеризации, что позволяет более точно анализировать и классифицировать отзывы, учитывая все упомянутые в них проблемы.
Все расчеты для статьи выполнялись на Google Colab с 12,7 Гб CPU и 15 Гб GPU.
Классический подход: LDA (Latent Dirichlet Allocation)
Начнем с классики — метода LDA (Latent Dirichlet Allocation), который появился задолго до эры трансформеров и нейросетей. Этот метод был разработан Дэвидом Блеем, Эндрю Ыном и Майклом Джорданом в 2003 году и описан в их статье в Journal of Machine Learning Research. LDA стал революционным подходом к моделированию коллекций дискретных данных, представляя документы как смеси скрытых тем, а каждую тему как распределение над словами.
До появления эмбеддингов были и другие подходы к тематическому моделированию:

Эти методы развивались поэтапно: от простых линейных алгебраических (LSA/LSI и NMF) к более сложным вероятностным (PLSA и LDA), которые позволяют лучше учитывать скрытую структуру данных.
LDA (Latent Dirichlet Allocation) работает следующим образом: алгоритм ищет в коллекции документов определенное количество тем и классифицирует каждый документ по этим темам. В результате мы получаем две матрицы:

Матрица «документ-тема» показывает распределение документов по темам, а матрица «тема-слова» показывает распределение слов по темам. То есть каждая тема характеризуется определенным набором слов.
Например, в коллекции документов, связанных с домашними животными, термины «собака», «спаниель» и «щенок» предполагают тему, связанную с собаками (DOG), в то время как термины «котенок», «сиамский» и «мурлыканье», предполагают тему, связанную с кошками (CAT). Каждое слово внутри темы будет иметь разную вероятность принадлежности к этой теме, при этом будут встречаться одни и те же слова, которые одновременно принадлежат к разным темам с разной вероятностью. Например, слово «дрессировка» может принадлежать к обоим темам, но с большей вероятностью к DOG. Каждый текст также будет принадлежать с определенной вероятностью к одной или нескольким темам.
Как мы применяли LDA к банковским отзывам
Вот как выглядит наш пайплайн для LDA:
предобработка текста;
векторизация;
снижение размерности (если нужно);
LDA.
Предобработка текста тут довольно стандартная — убираем все цифры, знаки препинания и лемматизируем оставшиеся слова.
Для векторизации используем CountVectorizer, а не TfidfVectorizer: для LDA необходимо истинное количество появлений каждого слова в документах, а TfidfVectorizer намеренно искажает частотность, придавая больший вес словам, которые часто встречаются в одном тексте и редко в остальных, что потенциально снижает точность LDA-модели.
Также для расчетов LDA мы не используем эмбеддинги типа Word2Vec/BERT. LDA — это вероятностная модель, которая ожидает на вход матрицу терм-документов, состоящую из простого подсчета слов в документе. В эмбеддингах же заложена дополнительная информация о контексте и порядке слов.
Далее нам необходимо уменьшить размерность получившейся матрицы. Это можно сделать через PCA/SVD, а можно сразу ограничить словарь через параметры CountVectorizer:
параметр max_df позволяет исключить слова, которые встречаются почти в каждом документе и поэтому вряд ли несут значимую тематическую информацию;
параметр min_df помогает отсеять слова, которые встречаются очень редко, а потому не дают важной информации, но расширяют словарь.
Кроме того, уберем стоп-слова, взяв для начала список stopwords из библиотеки nltk.
Все эти шаги — удаление стоп-слов, различных символов (знаков препинания, например), использование min_df, max_df и лемматизации позволит избежать «проклятия размерности», уменьшить необходимый объем оперативной памяти и существенно сократить время, необходимое на обучение модели LDA. Подробный ноутбук можно найти тут.
Для обучения модели мы взяли семь самых крупных категорий отзывов (согласно разметке платформы) и отфильтровали тексты короче 10 слов. Всего в наборе данных осталось 86 507 обращений.

Затем запускаем обучение LDA, задавая количество тем (n_components=7)
count_vectorizer = CountVectorizer(max_features=500, ngram_range=(1, 2), stop_words=stopwords, max_df=0.95, min_df=2)
dataset = count_vectorizer.fit_transform(df['all_text'])
lda = LDA(n_components = 7,
max_iter=10,
n_jobs=-1,
learning_method='batch',
random_state=42)
lda.fit(dataset)
После обучения выводим топ-10 слов, характеризующих каждый кластер, что позволит нам идентифицировать каждую из тем.
def display_topics(model, feature_names, no_top_words):
for topic_idx, topic in enumerate(model.components_):
print(f"Тема {topic_idx + 1}:", ", ".join([feature_names[i] for i in topic.argsort()[:-no_top_words - 1:-1]]))
cv_feature_names = count_vectorizer.get_feature_names_out()
display_topics(lda, cv_feature_names, 10)
После обучения LDA выдала нам следующие темы (по топ-10 слов для каждой):
вклад, процент, счет, условие, открыть, выгодный, ставка, накопительный, накопительный счет, остаток
кредитный, дебетовый, оформить, сотрудник, условие, заказать, оформление, привезти, курьер, рассказать
счет, услуга, средство, сотрудник, уровень, операция, высокий, обслуживание, перевод, обращение
сотрудник, поддержка, обслуживание, проблема, чат, помочь, вежливый, оператор, офис, отвечать
кредит, халва, кредитный, рассрочка, покупка, сумма, платеж, условие, брать, процент
кэшбэк, дебетовый, кэшбек, кешбэк, отличный, категория, обслуживание, нравиться, бонус, покупка
приложение, удобный, мобильный, мобильный приложение, перевод, удобный приложение, удобно, понятный, использование, комиссия
При использовании классических методов тематического моделирования требуется итеративная постобработка результатов, чтобы привести их к наиболее осмысленному виду. Поэтому дальше мы последовательно просматривали списки топ-слов для каждой темы и вручную убирали слова, которые никак не характеризуют тему, а являются общими.
Например, практически в каждом списке стоп-слов присутствует слово «банк». Кроме того, часто бывает так, что множество отзывов посвящены конкретному банку или банковскому продукту. Поскольку нам важно в целом понять проблематику отзывов, мы добавили в список стоп-слов названия банков и банковских продуктов и услуг. Итеративно добавляя эти слова в список стоп-слов, мы каждый раз заново обучали LDA-модель.
P. S. В представленном выше списке эта обработка уже проделана.
Можно интерпретировать эти кластеры так:
вклады;
кредиты наличными;
обслуживание;
дистанционное обслуживание;
кредитная карта;
дебетовая карта;
мобильное приложение.
Видно, что результаты тематического моделирования в целом соответствуют тем семи тематикам, которые мы подали на вход.
Но насколько хорошо модель справилась с задачей? Чтобы оценить это, мы назначили каждому отзыву одну категорию по максимальной вероятности:
pred = lda.transform(dataset)
df['predictions'] = np.argmax(pred, axis=1)
Затем построили гистограмму, которая отображает максимальную вероятность для каждого обращения — то есть «уверенность» модели в своих классификациях:

На графике видно три отчетливые модальности с пиками около 0,5, 0,82 и 0,9. Особенно интересна левая мода — она имеет широкую дисперсию и отвечает за большее количество назначений тематик. Это говорит о том, что модель часто не уверена в своих предсказаниях, и такая неуверенность объясняется сильным пересечением используемых слов в разных тематиках.
Когда мы сравнили предсказания модели с исходной разметкой, точность (Accuracy) составила всего 0,41. Не очень впечатляюще. А матрица ошибок выглядит так:

Итак, если считать, что разметка изначально верная (то есть пользователи платформы правильно выставили метку обращений), то результаты получаются неудовлетворительными. Нужно помнить, что метод LDA основан на подсчете слов без учета контекста их использования. Тем не менее алгоритм LDA вполне подойдет для предварительной кластеризации, для того, чтобы понять, о чем в целом говорится в обращениях и на какие примерно категории их можно разделить.
BERTopic
Для эффективного использования модели, особенно при работе с большими корпусами текстов, настоятельно рекомендуем задействовать графический ускоритель (GPU).
Пора перейти к более современным подходам. В отличие от LDA, языковые трансформеры избавляют от утомительной предобработки данных и автоматически определяют оптимальное количество тем. Вместо ручного перебора параметров алгоритм сам находит наиболее логичное число кластеров. Более того, BERTopic предлагает набор инструментов для визуализации тем и кластеров.
Классический пайплайн BERTopic прост:
векторизация;
снижение размерности (обязательный шаг!);
кластеризация.
Для работы с BERTopic практически не требуется предобработка. Достаточно удалить названия банков, чтобы модель не строила кластеры, опираясь на них, и можно приступать к делу.
Выбор языковой модели
Для векторизации мы можем использовать любой энкодер. Наш выбор (на конец 2024 года) основывался на поддержке русского языка, типе задачи, числе параметров, скорости работы и результатах на различных бенчмарках. Здесь вы можете найти более актуальную модель для ваших задач.
Мы же выбрали sergeyzh/LaBSE-ru-turbo с размером выходного эмбеддинга 768 — вполне достаточно для качественной векторизации текстов на русском языке.
Уменьшение размерности
Следующий шаг в фреймворке BERTopic — уменьшение размерности данных с помощью Uniform Manifold Approximation and Projection (UMAP). Этот метод отлично сохраняет как глобальную, так и локальную структуру данных и работает довольно быстро. Альтернативами могут послужить PCA и t-SNE, но UMAP часто оказывается оптимальным выбором.
По умолчанию параметры UMAP выглядят так:
umap_model = UMAP(
n_neighbors=15,
n_components=5,
min_dist=0.0,
metric=«cosine»,
low_memory=self.low_memory,
)
Для начала можно использовать эти параметры, а затем экспериментировать, подстраивая их под свои данные:
topic_model = BERTopic( ... umap_model=None ...)
Кластеризация
Стандартным алгоритмом кластеризации в BERTopic служит Hierarchical Density-Based Spatial Clustering of Applications with Noise (HDBSCAN). Его основная идея: кластеры — это области с высокой плотностью точек, окруженные «разреженными» участками.
HDBSCAN обладает двумя важными преимуществами: сам определяет количество кластеров и способен выявлять «шумовые» объекты — точки, не принадлежащие ни к одному кластеру. Начинаем со стандартных параметров:
hdbscan_model = hdbscan.HDBSCAN(
min_cluster_size=10,
metric=«euclidean»,
cluster_selection_method=«eom»,
prediction_data=True,
)
Теперь соединим все в один пайплайн:
texts = df['all_text'].tolist()
embedding_model = SentenceTransformer('sergeyzh/LaBSE-ru-turbo')
embeddings = embedding_model.encode(texts)
umap_model = UMAP(n_components=7, n_neighbors=5, min_dist=0.03, random_state=42)
hdbscan_model = hdbscan.HDBSCAN(min_cluster_size=100, min_samples=3)
topic_model = BERTopic(embedding_model=embedding_model, umap_model=umap_model, hdbscan_model=hdbscan_model)
topic_model.fit(texts, embeddings)
Важно понимать: хотя BERTopic прекрасно работает «из коробки», идеальное разделение на кластеры с первого раза — редкая удача. Такое возможно только на «чистых» данных, где темы значительно отличаются друг от друга. В реальной жизни документы часто содержат близкие по смыслу темы, которые сложно однозначно разделить, а HDBSCAN, стремясь минимизировать ошибки, создает множество мелких кластеров.
На первом запуске нашей целью было минимизировать кластер с шумом (кластер 1), чтобы модель распределила максимальное количество данных. Затем перед нами открылось несколько путей:
вручную проанализировать полученные кластеры и объединить похожие (эффективно, но трудоемко);
выполнить повторную кластеризацию топ-10 слов каждого кластера;
кластеризовать наиболее репрезентативные документы из каждого кластера;
объединить все документы одного кластера в единый текст и заново векторизовать.
Любой из этих методов требует ручной проверки на каждом этапе (что дает довольно субъективный результат). Мы остановились на первом варианте. Модель создала 89 кластеров, при этом более 28 тысяч отзывов были отнесены к шуму.
В итоге точность (Accuracy) модели составила примерно 0,63, что заметно лучше, чем у LDA (0,40), но все еще недостаточно для уверенной классификации отзывов.

Оптимизация BERTopic
Давайте подробней рассмотрим возможности BERTopic и попробуем довести его до совершенства. Для экспериментов создадим мини-датасет из 10 примеров и оптимизируем параметры модели (ноутбук можно найти тут).
Для оценки качества кластеризации будем использовать силуэтный коэффициент (Silhouette Score). Он показывает, насколько объект похож на другие объекты своего кластера по сравнению с объектами из соседних кластеров. Высокий силуэтный коэффициент говорит о хорошей кластеризации.
Вот наш тестовый набор данных:
data = {
'review_title': [
'Отличный банк', 'Хорошее обслуживание', 'Высокие комиссии', 'Неудобное приложение',
'Отличные кредиты', 'Медленное обслуживание', 'Хорошая поддержка',
'Высокие проценты', 'Плохой сервис', 'Удобный интерфейс'
],
'review_text': [
'Обслуживание на высшем уровне. ',
'Персонал вежливый, но ожидание было долгим ------',
'Комиссии за услуги слишком высокие!!!!!!',
'Приложение неудобное, сложно найти нужные функции',
'Низкие проценты по кредитам, гибкие условия',
'Обслуживание очень медленное, постоянно очереди',
'Поддержка клиентов решает вопросы быстро и эффективно',
'Проценты по кредитам слишком высокие, не рекомендую',
'Сервис оставляет желать лучшего, много ошибок и проблем',
'Интерфейс приложения очень удобный, все легко найти'
]
}
Ручной подбор параметров, приводит нас к такому результату:
embedding_model = SentenceTransformer('sergeyzh/LaBSE-ru-turbo')
umap_model = UMAP(n_neighbors=10, n_components=8, min_dist=0.1, random_state=42)
hdbscan_model = hdbscan.HDBSCAN(min_cluster_size=2, min_samples=1)
topic_model = BERTopic(embedding_model=embedding_model, umap_model=umap_model, hdbscan_model=hdbscan_model)
topics, probs = topic_model.fit_transform(texts)
Вот на какие кластеры делит данные алгоритм:

Кластеры легко интерпретируются:
0: обслуживание
1: проценты и услуги
2: мобильное приложение
Для проверки посмотрим топ-слова:
topic_model.visualize_barchart()

А вот как выглядит визуализация кластеров в двумерном пространстве:

Силуэтный коэффициент составил скромные 0,203 (диапазон от -1 до 1, чем ближе к 1, тем лучше разделены кластеры).
Затем мы оптимизировали параметры с помощью Optuna и получили силуэтный коэффициент 0,95. Кластеры стали выглядеть так:

Однако при ближайшем рассмотрении данных мы обнаружили парадоксальную картину:

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

В заголовке упоминаются «вклады», «накопительные счета» и «инвестиции», а в тексте обсуждаются «кредитные карты» и «обслуживание». При этом метка отзыва — просто «обслуживание».
Это значит, что при обучении LDA или BERTopic мы неизбежно получим смешанные кластеры вроде «мобильное приложение + вклад» или «дистанционное обслуживание + дебетовые карты». Для корректной оценки нам необходима мультилейбл-кластеризация.
Ни LDA, ни BERTopic прямо не поддерживают такую возможность, хотя оба метода позволяют получить вектор вероятностей принадлежности текста к каждой теме. Установив подходящий порог, можно реализовать базовую мультилейбл-кластеризацию.
Однако интуитивно правильное решение — сначала разделить отзыв на подтемы, а затем кластеризовать их по отдельности. Не найдя готовых инструментов для этой задачи, мы перешли на следующий уровень — большие языковые модели.
Large Language Model
В этом подходе мы использовали Large Language Model (LLM) для разбиения отзывов на тематические фрагменты с последующей классификацией каждого из них. Затем полученные «подотзывы» кластеризуются по темам.
Такой метод требует больше ресурсов и времени, но он обеспечивает хорошую интерпретируемость результатов. Мы можем отслеживать, какие именно темы LLM выделяет в каждом обращении, делая процесс анализа прозрачным и понятным. Более того, кластеризация конкретных фраз («вклад», «вклад с ежедневным процентом», «накопительный вклад») оказывается эффективной даже при использовании простых алгоритмов кластеризации.
Наш пайплайн выглядел так:
мультилейбл классификация текста с помощью LLM;
предобработка полученных тегов;
векторизация;
снижение размерности (обязательный этап);
кластеризация.
Поиск качественно размеченного датасета по банковским продуктам и услугам оказался безуспешным, поэтому мы создали собственный небольшой датасет из тех же отзывов — по 20 текстов для каждой из семи тем. Каждый отзыв получил от одного до семи тегов, указывающих на конкретные продукты или услуги, с которыми связаны комментарии клиента.
Вот пример размеченного отзыва:

Как видите, ручная разметка тегов точнее отражает смысл отзыва, чем обобщенный тег «обслуживание», присвоенный пользователем при его написании.
Для анализа отзывов мы выбрали модель QWEN2.5-7b-instruct, которая обеспечивает отличный баланс между качеством и требованиями к ресурсам (здесь вы можете найти более актуальные модели). Наша прекрасно работает в Google Colab, а для анализа длинных отзывов подойдет ее квантизованная версия.
model_name = "Qwen/Qwen2.5-7B-Instruct"
model = AutoModelForCausalLM.from_pretrained(
model_name,
# quantization_config=quantization_config,
torch_dtype="auto",
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
pipe = pipeline(task='text-generation',
model=model,
tokenizer=tokenizer,
return_full_text=False,
max_new_tokens=20,
temperature=1e-6,
top_p=0,
do_sample=False)
llm = HuggingFacePipeline(pipeline=pipe)
template = """
...
Текст: {text_input}
Ответ:"""
prompt_template = PromptTemplate(input_variables=['text_input'],
template=template)
Для каждого текста выполняем:
prompt = prompt_template.format(text_input=text)
answer = llm.invoke(prompt)
Самая интересная часть — это template. Мы используем FewShotPromptTemplate, который включает в каждый промпт несколько примеров коротких диалогов с тегами, соответствующими ожидаемым темам.
Вот пример промпта для модели:
template = """
Ты эксперт по банковским продуктам.
Тебе будет дан текст - отзыв клиента о работе банка и его услугах.
Твоя задача — проанализировать текст и определить, по поводу какого продукта или услуги написан отзыв.
Если ты не можешь найти упоминания продукта или услуги, то не выводи ничего.
К меткам предъявляются следующие требования:
- В метках должны упоминаться только продукты банка
- Метки не должны включать названия банков
- Метки должны быть короткими (1–3 слова)
- Каждая метка должна быть уникальной (без повторений)
Примеры:
Текст: Встречался с представителем банка по поводу получения дебетовой карты. Очень понравился представитель, также предложил получить платежный стикер, я согласился, так как человек был очень добр, вежлив и аккуратен в общении. Не грубил, рассказывал все подробно, на все свои вопросы получил ответ. Встречей остался доволен.
Ответ: Дебетовая карта, Платежный стикер
Текст: Благодарю представителя банка, который доставил мне дебетовую карту четко в обещанный день и даже время. Рассказал, какие бонусы, плюсы и отличия от других банков. А еще помог установить приложение на телефон.
Ответ: Дебетовая карта, Приложение
...
Текст: {text_input}
Ответ:"""
С помощью промпт-инжиниринга мы добавляли примеры и анализировали качество выделения тем LLM-моделью, постепенно улучшая результаты.
После получения тегов необходима их предобработка. Несмотря на указание избегать названий банков, они иногда появляются в тегах. Такие упоминания нужно удалять, чтобы не искажать результаты кластеризации. Все обработанные теги объединяются в общий список и передаются в BERTopic для дальнейшей кластеризации аналогично описанному ранее примеру.
BERTopic не позволяет напрямую задать число кластеров, но после кластеризации у нас есть два варианта:
объединить близкие темы вручную с помощью метода topic_model.merge_topics(...);
автоматически сократить количество кластеров до нужного уровня методом topic_model.reduce_topics(...).
Кроме того, параметры BERTopic позволяют косвенно влиять на итоговое число кластеров. Например, увеличение параметра min_cluster_size приведет к уменьшению общего количества кластеров. В зависимости от доступного времени и требований к детализации можно регулировать «грубость кластеризации». Важно помнить о компромиссе: чем крупнее кластеры, тем больше данных попадает в «шум», а чем мельче кластеры, тем сложнее их повторно кластеризовать.
В нашем случае из 380 тегов образовалось около 30 кластеров, которые затем были вручную объединены в семь целевых тематик.
Для наглядности можно визуализировать кластеры с помощью функции
topic_model.visualize_topics():

На первом рисунке видно, что кластеры 1 и 28, связанные с мобильным приложением, идеально наложились друг на друга, — явный признак, что их следует объединить.

А вот кластеры 0 и 29, несмотря на близкое расположение, отражают разные темы — «Обслуживание» и «Переводы», что говорит о хорошем семантическом разделении.
Финальная метрика Жаккарда при выбранной настройке кластеров составила впечатляющие ~0,7 — отличный результат для задачи мультилейбл-кластеризации!
Перейдем к выводам
В заключение хочу сказать, что тематическое моделирование — мощный инструмент разведочного анализа. Хотя его применение в предсказательной аналитике ограничено, оно позволяет обнаруживать скрытые закономерности и глубже понимать структуру информации.
Оценка качества результатов во многом зависит от контекста и целей исследования, но в сочетании с современными методами машинного обучения тематическое моделирование значительно расширяет аналитические возможности и способствует принятию более обоснованных бизнес-решений.