Используем LLM, чтобы найти «бриллианты» в тексте

3332c5350cdb9a9fc02b7d9608d99cd4.png

Привет всем! Меня зовут Александр Григорьев и я продуктовый аналитик в Innovative People. 

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

Спустя время у меня и моих коллег накопилось несколько вопросов:

  1. Чем можно заменить недоступный из РФ OpenAI?

  2. Как найти, какое количество тем в моём наборе данных?

  3. Как «измерить» те инсайты, которые мы получили?

  4. Насколько целесообразно это использовать?

По порядку обо всём этом я расскажу в своей статье.

Intro

Чтобы показать более наглядно, как это всё работает, вооружимся какой-либо средой для работы с Python. Идеально подойдёт Jupyter Notebook, потому что в процессе мы будем выводить графики и там удобно с ними работать.

Для этого нам будет нужно установить зависимости

!pip install gigachat==0.1.30 python-dotenv==1.0.1 tqdm==4.66.4 pandas==2.1.4 numpy==1.26.4 plotly==5.22.0 scikit-learn==1.5.1

И как же без задачи, верно?

Для примера я предлагаю провести исследование и выяснить, с какими заголовками лучше всего «выстреливали» статьи на хабре.

Данные я брал из датасета с открытой лицензией, но оставил только данные по 2024 году, чтобы их было не слишком много, и с тегом «игры», потому что эта тема относительно нейтральная и без «выбросов».

За целевую переменную возьмём метрику score у статьи, что соответствует её рейтингу.

ДЗ

В качестве «домашнего задания» можете поэкспериментировать с целевой метрикой, чтобы сделать её более репрезентативной. Например, вывести конверсию в рейтинг из просмотров или рассчитать её для отдельных тегов.

GigaCha (d/t)

За провайдера эмбеддингов в этот раз возьмём GigaChat API. Из плюсов:  

  • API доступно для оплаты, в том числе, для физлиц. Это сделает её пригодной для ваших пет-проектов

  • Эмбеддинги имеют достаточное качество и количество параметров, что делает их пригодными и для серьёзных проектов.

Почему и когда стоит пользоваться провайдерами API для ML-моделей, я писал ранее в предыдущей статье.

Для начала импортируем библиотеки и переменные окружения

import pandas as pd
from os import environ
from pathlib import Path  # python3 only
from dotenv import load_dotenv

env_path = Path('.') / '.env'
load_dotenv(dotenv_path=env_path)

giga_chat_token = environ.get('GIGA_CHAT_TOKEN')

Загрузим заранее подготовленный датасет c информацией о статье:

df = pd.read_csv('./habr_games_2024.csv')
df
#        id                                             header  score
# 0  779218  Dead by Daylight: мyльтиплeeрный yжac или крoв...     37
# 1  784446  Пoлнaя иcтoрия игрoвoй вceлeннoй "Гaрри Пoттeр...     37
# 2  784478  Beyond Good & Evil: Иcтoрия нeдooцeнeннoгo шeд...     17
# 3  784574  К 30-лeтнeмy юбилeю DOOM: кaк двa caдиcтa, пcи...     67
# 4  784684  Кaк я нaкoнeц ocyщecтвил мeчтy зaнятьcя рaзрaб...     48

И соберём эмбеддинги:

from gigachat import GigaChat

with GigaChat(credentials=giga_chat_token, verify_ssl_certs=False) as giga:
   df.loc[:, "embedding"] = emb_df['header'].progress_apply(lambda x: giga.embeddings(x).data[0].embedding)
# 100%|██████████| 114/114 [04:04<00:00,  2.14s/it]

В итоге у нас получился такой датафрейм с эмбеддингами от GigaChat:  

#        id                                             header  score                                           embedding 
# 0  779218  Dead by Daylight: мyльтиплeeрный yжac или крoв...     37   [0.370638370513916, -1.287872314453125, -0.520... 
# 1  784446  Пoлнaя иcтoрия игрoвoй вceлeннoй "Гaрри Пoттeр...     37   [1.28215754032135, -0.7009274959564209, -0.974... 
# 2  784478  Beyond Good & Evil: Иcтoрия нeдooцeнeннoгo шeд...     17   [0.3682066798210144, -1.5936537981033325, -0.5... 
# 3  784574  К 30-лeтнeмy юбилeю DOOM: кaк двa caдиcтa, пcи...     67   [0.8448407649993896, -0.371548056602478, -0.58... 
# 4  784684  Кaк я нaкoнeц ocyщecтвил мeчтy зaнятьcя рaзрaб...     48   [0.6090085506439209, -0.9120537042617798, -0.6...

Рассчитываем количество кластеров (=тем)

Вообще, с количеством кластеров можно экспериментировать вручную, т.к. :

  1. Количество тем (=кластеров) обычно можно прикинуть заранее.

  2. Пересчитать кластеры обычно не так долго и сложно с точки зрения вычислений.

Но, предположим, что мы понятия не имеем, сколько у нас тем в датасете. Поэтому нужен метод для поиска оптимального количества кластеров.       

Воспользуемся методом «силуета», потому что он относительно простой и эффективный. К тому же, вероятно, в данных будет много шума и этот метод более устойчив к нему, чем остальные.

Для кластеризации возьмём метод К-средних как самый простой и популярный:

# Преобразовываем в матрицу для кластеризации
matrix = np.vstack(df['embedding'].values)
matrix.shape
# (114, 1024)

from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

max_k = 50 # возьмём 50 как максимальное количество кластеров
silhouette_avg = [] # list для метрик
iters = [i for i in range(2, max_k + 1)] # list для количества кластеров

# для каждого N кластеров считаем метрики
for k in iters:
   kmeans = KMeans(n_clusters=k, init="k-means++", random_state=42)
   kmeans.fit(matrix)
   score = silhouette_score(matrix, kmeans.labels_)
   silhouette_avg.append(score)
   print(f"Коэффициент «силуэт» {score} для n_clusters = {k}")

# Коэффициент «силуэт» 0.06416511230205846 для n_clusters = 2
# Коэффициент «силуэт» 0.03993803155158418 для n_clusters = 3
# Коэффициент «силуэт» 0.07506241541333307 для n_clusters = 4
# Коэффициент «силуэт» 0.07622179155732355 для n_clusters = 5
# ...
# Коэффициент «силуэт» 0.07009252381830818 для n_clusters = 49
# Коэффициент «силуэт» 0.0704183886000937 для n_clusters = 50

Визуализируем для наглядности:

import plotly.express as px

fig = px.line(x=iters, y=silhouette_avg)
fig.update_layout(title='Коэффициент «силуэт» для разного k', xaxis_title='Количество кластеров', yaxis_title='Коэффициент')
fig.show()

d85fd8acd99284024d05444ce13cefd6.png

Как мы видим, самое большое значение коэффициента при k=5. Поэтому далее и будем использовать это значение.

Кластеризуем

n_clusters = 5 # Количество кластеров можно менять по усмотрению, но выше мы нашли оптимальное

kmeans = KMeans(n_clusters=n_clusters, init="k-means++", random_state=42)
kmeans.fit(matrix)
labels = kmeans.labels_
df["cluster"] = labels

# Переводим номер кластера в строку, потому что так будет удобнее для визуализации
df["cluster"] = df["cluster"].astype(str)

Перед тем как визуализировать, попробуем описать распределение рейтингов в датасете и в получившихся кластерах:

df['score'].describe()
# count    114.000000
# mean      17.114035
# std       20.452734
# min        1.000000
# 25%        4.000000
# 50%        8.000000
# 75%       20.000000
# max       85.000000
# Name: score, dtype: float64

По общим значениям уже видно, что у 75% статей рейтинг до 20. Разрыв между высоким рейтингом и средним достаточно большой:

df.groupby('cluster', as_index=False)['score'].describe()
#   cluster  count       mean        std   min    25%   50%   75%   max
# 0       0    6.0  17.666667  14.193895   5.0   6.25  13.5  26.0  40.0
# 1       1   19.0   5.789474   3.047384   2.0   3.00   6.0   6.5  14.0
# 2       2   47.0   8.744681  10.985105   1.0   3.00   5.0  10.0  48.0
# 3       3    7.0  69.857143   8.610625  59.0  64.50  69.0  73.5  85.0
# 4       4   35.0  23.857143  19.781368   1.0   8.00  20.0  35.5  71.0

Сгруппировав по кластерам, мы уже видим, что в кластере под номером три сконцентрировалось 7 тем с очень высоким средним рейтингом. Что эта за тема, узнаем позднее.

Визуализируем кластеры

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

Воспользуемся t-SNE для визуализации кластеров. t-SNE «упрощает» данные  с некоторой минимальной потерей значимости так, чтобы мы могли это визуализировать в 2D-пространстве. Это будет полезно, так как эмбеддинги по своей сути — это многомерное представление текста.

# Получаем t-SNE проекцию
from sklearn.manifold import TSNE

tsne = TSNE(n_components=2, perplexity=15, random_state=42, init="random", learning_rate=1000, n_jobs=-1)
vis_dims2 = tsne.fit_transform(matrix)

x = [x for x, y in vis_dims2]
y = [y for x, y in vis_dims2]

# Записываем полученные координаты t-SNE в исходный датафрейм
df['x'] = x
df['y'] = y

Визуализируем:

fig = px.scatter(df, x='x', y='y', hover_data=['header', 'id'], color='cluster')
fig.update_layout(title='Визуализация t-SNE')
fig.show()

df946e7ee8c1e4f7c48eb95e2104540f.png

Пока мы видим, что получившиеся кластеры сгруппировались в «кучки» на графике t-SNE. 

Для визуализации я использовал фреймворк Plotly. График получился динамический, и при наведении курсора на точку отобразится номер кластера, заголовок и координаты. 

Если прощелкать точки, то можно, например, заметить некоторые выделенные темы-кластеры. 

Также плюсом будет оставить только определенные темы-кластеры и протыкать , какие конкретно темы есть в них.

Уже сейчас можно предположить, по какому принципу кластеры сгруппировались:  

  • Кластер 0. Ремейки и старые игры. Например, там есть статья 35 лeт игрe «Prince of Persia»

  • Кластер 1. Показатели продаж. Например, «Вырyчкa фрaншизы World of Tanks дocтиглa oтмeтки $7 млрд»

  • Кластер 2. Новости. Например, «Рoccийcкиe рaзрaбoтчики рaбoтaют нaд aнaлoгoм SimCity»

  • Кластер 3. DOOM. Забавно, что если провести линии через точки кластера, то получится пентаграмма (сам кластер на графике зеленый справа внизу) 

  • Кластер 4. Гайды и истории. Например, «Мexaники yдeржaния в игрax»

Теперь визуализируем рейтинг:

8ee664457a5fb7df6c1d7dc7cbe30b08.png

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

Во-первых, это DOOM. Тут без комментариев, т.к. выделяется объёмная серия статей про саму игру. 

Во-вторых, это четвертый кластер с отдельными историями и гайдами. Например, выделились по рейтингу статья про 8-битные игры и Atomic Heart. 

В целом, стало понятнее про статьи с играми на Хабре!

В итоге

Стоило ли это того? На мой взгляд, конечно, стоило!

Эмбеддинги стоят достаточно дешево. На данный момент физлицо может купить 10 миллионов токенов всего за 400 рублей. На эту сумму токенов можно проанализировать примерно 200 тысяч заголовков.

На анализ и написание кода тратится 4–6 часов, как и на обычную исследовательскую задачу.

И ещё мы выяснили, что DOOM даёт неплохой результат по рейтингу.

P.S. Автор пошел писать статью как запустить DOOM в графике Plotly через Jupyter.

© Habrahabr.ru