Привет!
Два года назад я создал телеграмм-канал и начал постить туда всякое, что считал интересным. Изначально это было что-то вроде публичного дневника с регулярными и короткими заметками из моей студенческой жизни. После, я попробовал превратить этот канал в блог о современном искусстве, ну а пару месяцев назад понял, что не могу жить без любимой айтишечки и начал постить в канал новости из мира Data Science и ИИ.
И вот, спустя 2 года, мне пришло в голову, что телеграмм-канал - это довольно необычный источник текстов. Я у мамы дата сайнтист, так что на этих данных и решил устроить себе небольшой NLP-Этюд, чтобы попрактиковаться и пощупать новые инструменты. Процессом и результатами своей работы я поделюсь в этой статье.
Содержание:
-
Загрузка и предобработка данных из Telegram
-
Статистический анализ текстов
-
Тематическое моделирование
-
Sentiment analysis и баловство с трансформерами
-
Визуализация эмбеддингов и кластерный анализ
Загрузка данных
Перед тем как начать что-то там анализировать, мне нужно было эти данные добыть. Благо, в телеграмме выгрузка делается в два клика мышью, так что это не вызвало никаких трудностей.
Для того, чтобы получить выгрузку из любого телеграм-канала, нужно открыть его и нажать на три точки в правом верхнем углу. В выпавшем меню жмём на кнопку "export chat history" В новом окне отключаем все предлагаемые опции, чтобы не загружать фото или файлы из канала (сегодня мы будем анализировать только тексты сообщений). Как формат выбираем JSON, выбираем путь для загрузки и жмём на кнопку экспорт.Как получить данные из телеграмм-канала?
Теперь эти данные нужно загрузить в среду, в которой будем дальше работать. В моём случае, я буду обрабатывать текст в Python.
Так исторически сложилось, что JSON выгрузка из телеграмма имеет большую степень вложенности, так что простая функция read_json()
из библиотеки pandas нам не подойдёт. Вместо неё мы используем функцию json_normalize()
, которая превращает JSON с кучей вложенных структур в плоскую таблицу.
import json
import pandas as pd
with open("./data/result.json") as f:
data = json.load(f)
messages = data['messages']
df = pd.json_normalize(messages)
Полученный pd.DataFrame
имеет весьма устрашающий облик из-за обилия в нём технической и малоинтересной для нас информации, вроде id действующего лица, ширины и высоты приложенного фото и т.п. Нас же интересует именно текстовая сторона данных, так что мы оставляем только столбцы с id сообщения, датой и временем выхода поста и текстом.
Но телеграмм довольно необычно хранит тексты сообщений. Из-за возможностей форматирования текста, в нашей табличке он выглядит примерно вот так:
['Можете называть меня странным, но я получаю абсолютно неподдельное удовольствие от прогулок на google картах… 🤓nnТочнее, не совсем на google картах, а на ',
{'type': 'text_link', 'text': 'Google Earth.', 'href': '[https://www.google.ru/intl/ru/earth/](https://www.google.ru/intl/ru/earth/)'},
' Это такой супер-детальный цифровой глобус. nnЯ прям искренне наслаждаюсь вот этим чувством исследования и открытия, когда на панорамах какого-то далёкого города нахожу интересное и красивое место или просто смешную подпись 😁 nНастоятельно советую и вам открыть Google Earth и залипнуть на часик-другой в цифровом путешествии 🗺️']
Чтобы извлечь только текстовые данные из такого формата записи я написал вот такую функцию:
# Преобразуем текст из списка в обычную строку
def extract_text(row: pd.Series) -> str:
if type(row['text']) == str:
return row['text']
else:
t = ''
for block in row['text']:
if type(block) != str:
t += block['text']
else:
t += block
return t
df['text'] = df.apply(extract_text, axis=1)
df['text'] = df['text'].str.replace('n', ' ')
После этого, мы быстренько вычищаем из текста все эмодзи и знаки препинания и переходим к самому интересному: к циферкам!
Код отчистки текста от эмодзи
def deEmojify(text):
regrex_pattern = re.compile(pattern = "["
u"U00000000-U00000009"
u"U0000000B-U0000001F"
u"U00000080-U00000400"
u"U00000402-U0000040F"
u"U00000450-U00000450"
u"U00000452-U0010FFFF"
"]+", flags = re.UNICODE)
punctuation_pattern = re.compile(pattern = r'[^ws]', flags = re.UNICODE)
return punctuation_pattern.sub(r'', regrex_pattern.sub(r'',text))
df['cleaned_text'] = df['text'].apply(deEmojify)
df.cleaned_text = df.cleaned_text.str.lower()
Статистический анализ
Первым делом, посчитаем классические метрики, в духе общего количества слов и символов и т.п.
all_texts = ' '.join(df['cleaned_text'].to_list())
while ' ' in all_texts:
all_texts = all_texts.replace(' ', ' ')
print(f'Общее количество символов в моём канале: {len(all_texts)}')
print('Общее количество слов в моём канале:', len(all_texts.split()))
print('Количество "уникальных" слов в моём канале:', len(set(all_texts.split())))
И получаем следующие значения:
-
Общее количество символов в моём канале: 137823
-
Общее количество слов в моём канале: 21607
-
Количество "уникальных" слов в моём канале: 7446
Количество уникальных слов пока берём в кавычки, т.к считать этим способом не очень корректно, ведь одно и тоже слово может употребляться в разных формах + никто не отменял опечатки. Чуть позже посчитаем эту метрику корректно.
Самым длинным моим постом за 2 года стал разбор судебного разбирательства вокруг компании Perplexity.ai. Его длина составила 3418 символов или 499 слов.
Раз у нас есть информация о дате и времени выхода постов, интересно будет посмотреть на то, как менялось количество постов в день с течением времени. Чтобы посчитать это, нужно преобразовать столбец с датой в тип данных pd.DateTime
и сгруппировать табличку по дням:
df['datetime'] = pd.to_datetime(df['date'])
# Получим только дату из информации о дате и времени
df['date'] = pd.to_datetime(df['datetime'].dt.date)
amount_by_date = df.groupby('date', as_index=False)
.agg({'id': 'count'})
.sort_values('date')
amount_by_date = pd.merge(
# т.к есть дни, в которые ни одного поста не выходило, нужно добавить в таблицу эти дни и заполнить количество постов нулями
pd.Series(pd.date_range(start=df['date'].min(),end=df['date'].max()), name='date'),
amount_by_date,
how='left').fillna(0)
Получаем мы табличку примерно вот такого вида:
Но анализировать эти данные нам будет намного удобнее с помощью графика:
Код создания графика
import plotly.express as px
fig = px.scatter(data_frame=amount_by_date, x='date', y='id', title='Количество постов по дням', trendline='ols')
fig.update_traces(mode = 'lines')
fig.data[-1].line.color = 'red'
fig.show()
Как мы видим, количество постов в день довольно непостоянно. Есть как дни, когда выходило несколько текстов, так и периоды отсутствия контента длинною в месяц. Максимальное количество постов (11 штук) было 26 декабря 2022 года. В тот вечер я готовился к экзамену по матану и был готов заниматься чем угодно, кроме подготовки...
Красной линией на этом графике я изобразил тренд. Он указывает на то, что среднее количество постов в день постепенно уменьшается. Если на момент создания канала оно достигало значения 1.12 постов в день, то сейчас оно всего 0.54 поста в день.
При этом, среднее количество слов, написанных в канале в день, постепенно растёт. Могу связать это с тем, что постов становится меньше, но они становятся длиннее и информативнее.
Касаемо периодов максимальной активности, больше всего постов вышло в период с 18:00 до 19:00 (64 штуки). Что интересно, посты выходили как днём, так и глубокой ночью и ранним утром. Единственным промежутком времени, когда не вышло ни одного поста стал час с 2:00 до 3:00. Объясняется это тем, что в этот промежуток времени я сплю.
Лемматизация
Для того, чтобы всё-таки получить корректное число уникальных слов, которые фигурировали в текстах на моём канале, необходимо привести все слова к начальной форме, а также избавиться от так называемых стоп-слов.
Для приведения слова к начальной форме существует метод лемматизации, то есть преобразования словоформы к её лемме. Лемматизация здорово реализована в билиотеке pymystem3
. Её я и буду использовать. Список стоп-слов (т.е слов, которые настолько часто встречаются в языке, что теряют особую смысловую нагрузку) я возьму из библиотеки nltk
.
from nltk.corpus import stopwords
from pymystem3 import Mystem
from string import punctuation, digits
mystem = Mystem()
russian_stopwords = stopwords.words("russian")
local_stopwords = ['это', 'очень', 'который', 'весь', 'свой', 'наш', 'хотеть']
def preprocess_text(text):
tokens = mystem.lemmatize(text.lower())
tokens = [token for token in tokens if token not in russian_stopwords
and token != " "
and token.strip() not in punctuation
and token not in digits
and token not in local_stopwords]
text = " ".join(tokens)
return text
df['stemmed_text'] = df['cleaned_text'].apply(preprocess_text)
Результат лемматизации выглядит примерно так:
И вот теперь, когда все слова приведены к начальной форме, мы можем корректно посчитать количество уникальных слов, которые я применил в канале:
all_texts = ' '.join(df['stemmed_text'].to_list())
while ' ' in all_texts:
all_texts = all_texts.replace(' ', ' ')
print('Количество реально уникальных слов в моём канале:', len(set(all_texts.split())))
И получаем мы значение 4480 уникальных слова.
В этот момент мне стало интересно посмотреть на то, какие слова я чаще всего употреблял в текстах. Посчитать это я смог с помощью вот такой вот чудовищной конструкции:
freq_words = pd.DataFrame([(all_texts.split().count(word), word)
for word in set(set(all_texts.split()))],
columns=['freq', 'word'])
freq_words.sort_values('freq', ascending=False).head(10)
Получаем мы вот такой вывод и понимаем, что я часто что-то могу, делаю что-то в первый раз и регулярно пишу о том, что происходит в данный конкретный момент (день, сегодня).
Тематическое моделирование
Когда с измеримыми показателями мы более-менее разобрались, захотелось попробовать проанализировать более эфемерную характеристику - тему текстов. Именно в этот момент я узнал про такую область анализа текстов, как тематическое моделирование. Это подход, который позволяет выявлять неявные тематические структуры в наборе текстов.
Из входных данных алгоритму нужно только векторное представление текстов (зачастую bag-of-words или TF-IDF) и количество тем, на которые нужно эту коллекцию разделить. Подробнее про тематическое моделирование написано в статье от OTUS и в моём блоге выходил большой пост.
Я тоже решил применить метод тематического моделирования для анализа текстов в моём канале. Для решения задачи я выбрал модель LDA (Latent Dirichlet Allocation), поскольку он, несмотря на название, довольно прост в настройке и использовании. Реализацию этого метода я взял из библиотеки scikit-learn
.
Первым делом, нужно было преобразовать наши лемматизированные тексты в векторные представления, с помощью метода bag-of-words. Для этого я использовал CountVectorizer()
из того же sklearn
. Он преобразует тексты в разряженные векторы с количеством компонент равным количеству уникальных слов в корпусе текста. В столбце ставится 0, если текст не включает слово, соответствующее данной компоненте, или число равное количеству вхождений слова в текст.
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(max_df=0.1, min_df=7)
X = vectorizer.fit_transform(df['stemmed_text'])
В данном примере кода параметры max_df
и min_df
отвечают за то, какие слова нужно добавлять в векторное представление, а какие нет. В моём случае, я исключаю все слова, которые занимают >10% всех слов (т.е слишком частотные слова) и исключаю слова, которые встретились менее 7 раз (т.е слишком редкие слова). Эти параметры подбираются эмпирически (простым перебором).
После векторизации получаем матрицу размером 598 (к-во постов) на 319 (к-во слов, используемых в векторизации). И вот на этой матрице будем обучать алгоритм LDA.
from sklearn.decomposition import LatentDirichletAllocation
amount_of_topics = 3
lda = LatentDirichletAllocation(n_components=amount_of_topics,
doc_topic_prior=0.1,
topic_word_prior=0.03,
random_state=1210)
lda.fit(X)
topic_list = [f'topic_{i+1}' for i in range(amount_of_topics)]
Давайте чуть подробнее рассмотрим параметры модели:
-
n_components
- это, собственно, количество тем, на которые мы хотим разделить наш набор текстов. -
doc_topic_prior
- это то, сколько тем может быть в одном документе. Маленькое значение этого параметра приведёт к тому, что будет всего 1 доминирующая тема на документ. Большие значения, наоборот, приведут к наличию нескольких возможных тем. -
topic_word_prior
- примерно то же самое, что иdoc_topic_prior
, но связанное со словами. Чем больше это значение, тем к большему количеству тем может относиться одно слово. Если указать тут какое-то маленькое значение, то получится, что одно слово может принадлежать только к одной теме. -
random_state
- число, которое просто выключает рандом в модели. Нужно оно для того, чтобы при одних и тех же входных данных мы получали одни и те же выходные значение. Тут можете ставить любое число, которое вам нравится.
Как я понял, подбор этих гиперпараметров - отдельный вид искусства, который нужно просто прочувствовать. Никто точно не сможет сказать, сколько тем вам нужно и какие циферки ставить в doc_topic_prior
и topic_word_prior
. Нужно просто эксперементировать и выбирать те значения, которые дают наиболее релевантные результаты.
После обучения этой модельки мы можем посмотреть на то, какие слова к каким темам она отнесла:
def print_top_words(model, feature_names, n_top_words):
for topic_idx, topic in enumerate(model.components_):
print(f"Topic #{topic_idx+1}:")
print(" ".join([feature_names[i]
for i in topic.argsort()[:-n_top_words - 1:-1]]))
n_top_words = 15
print_top_words(lda, vectorizer.get_feature_names_out(), n_top_words)
И получаем результаты вот такого вида:
Topic #1:
сегодня день самый пленка решать тюмень фотография человек город довольный москва момент получаться хороший просто
Topic #2:
модель область человек число интересный мочь функция самый данные информация писать вопрос работа нейросеть сдавать
Topic #3:
год фильм мочь время новый большой понимать находить работа канал просто проект пост искусство день
Я характеризую эти слова, как маркеры тем. Если данное слово встречается в тексте, то вероятность того, что он относится к той или иной теме повышается. Взглянув на эти маркеры невооружённым глазом можно заметить какую-то связь между словами, но внятно определить какая тема за что отвечает тяжело. Так что мы применим метод, который спасает в любой непонятной ситуации - спросим у ChatGPT.
Запрос к ChatGPT
"Я занимаюсь тематическим моделированием и только что я применил метод LDA для определения 3 тем в некотором корпусе текстов.
Вот результат работы модели:
Topic #0:
сегодня день самый пленка решать тюмень фотография человек город довольный москва момент получаться хороший просто
Topic #1:
модель область человек число интересный мочь функция самый данные информация писать вопрос работа нейросеть сдавать
Topic #2:
год фильм мочь время новый большой понимать находить работа канал просто проект пост искусство день
Это слова, которые наиболее явно относятся к той или иной теме.
Пожалуйста, проанализируй данный результат и сформулируй для меня подробное описание каждой из тем.
Что она может затрагивать? Какие у неё отличительные особенности? Избегай абстрактных и неоднозначных формулировок!"
Недолго думая, ChatGPT распределила темы так:
-
Тема #1 фокусируется на повседневной жизни и событиях, связанных с городами и их жителями.
-
Тема #2 сосредоточена на науке, технологиях и обработке данных. Ключевые слова указывают на работу с моделями, данными и функциями, а также на вопросы, связанные с нейросетями и информацией.
-
Тема #3 охватывает широкий спектр вопросов, связанных с культурой, искусством и медиа. Ключевые слова указывают на фильмы, проекты, искусство, а также временные аспекты и крупные события.
И, в целом, модель довольно точно распределила темы! Это именно то, о чём я пишу в своём канале. Круто, что без моего особого участия 2 модели машинного обучения смогли очень хорошо распределить крупный набор текстов на осмысленные и логичные темы. Как по мне, выглядит впечатляюще.
Если посмотреть на количество постов разной тематики, то оказывается, что чаще всего я пишу о повседневной жизни и о всяких событиях.
При этом, так получилось, что именно посты о повседневной жизни самые короткие. Могу предположить, что связано это с тем, что их я обычно пишу на ходу, без особой подготовки и осмысления. При этом посты о культуре и технологиях имеют примерно одинаковую среднюю длину.
Sentiment analysis и баловство с трансформерами
Сейчас мы перейдём скорее к весёлому баловству, чем к серьёзному анализу, но возможно и тут будет что-то полезное.
Есть такая платформа - hugging face. Это как github, но для дата сайнтистов и вместо кода там, зачастую модели машинного обучения и всяческие нейросети. Покопавшись там я нашёл несколько моделей, которые умеют работать с русским языком и решил применить их в моём анализе.
Первая на очереди у нас модель rubert-tiny2-russian-emotion-detection от команды Aniemore. Как ясно из названия, модель распознаёт настроение текста.
По инструкции от разработчиков загружаем модель и скармливаем ей все посты из канала:
import torch
from transformers import BertForSequenceClassification, AutoTokenizer
EMOTION_LABELS = ['neutral', 'happiness', 'sadness', 'enthusiasm', 'fear', 'anger', 'disgust']
emotion_tokenizer = AutoTokenizer.from_pretrained('Aniemore/rubert-tiny2-russian-emotion-detection')
emotion_model = BertForSequenceClassification.from_pretrained('Aniemore/rubert-tiny2-russian-emotion-detection')
@torch.no_grad()
def predict_emotion(text: str) -> str:
inputs = emotion_tokenizer(text, max_length=512, padding=True, truncation=True, return_tensors='pt')
outputs = emotion_model(**inputs)
predicted = torch.nn.functional.softmax(outputs.logits, dim=1)
predicted = torch.argmax(predicted, dim=1).numpy()
return EMOTION_LABELS[predicted[0]]
df['emotion'] = df['cleaned_text'].apply(predict_emotion)
Первым делом, посмотрим на value_counts()
полученного столбца, чтобы узнать количественное соотношение разных настроений в моих текстах:
Как и следовало ожидать, большая часть постов нейтральные или весёлые, но, к моему удивлению, модель определила 78 постов как злые... Это почти каждый седьмой пост в канале, получается. Но когда я заглянул в таблицу и увидел, что модель распознала пост с текстом "Надо было ставить Linux" как злой, то быстро понял, что вот эти 78 постов - простые ошибки модели.
Оно и не удивительно. Многоклассовая классификация - сложная задача и тот факт, что модель корректно распознала нейтральные и позитивные тексты уже очень здорово.
Далее, проверим мой канал на токсичность с помощью модели russian_toxicity_classifier:
from transformers import pipeline
toxic_pipeline = pipeline(
task = 'sentiment-analysis',
model = 's-nlp/russian_toxicity_classifier')
tokenizer = AutoTokenizer.from_pretrained('s-nlp/russian_toxicity_classifier')
def define_toxic(text: str) -> str:
# У данной модели есть ограничение по длине текста, так что приходится выполнять сокращение текстов до 512 токенов вручную с помощью параметра max_length.
tokens = tokenizer.encode(text, max_length=512, truncation=True)
truncated_text = tokenizer.decode(tokens, skip_special_tokens=True)
return toxic_pipeline(truncated_text)[0]['label']
df['toxicity'] = df['cleaned_text'].apply(lambda x: x if len(x.split(' ')) < 510 else ' '.join(x.split(' ')[:510])).apply(define_toxic)
После выполнения этой программы я получил уже намного более правдоподобные результаты. Так оказалось, что я написал всего 5 токсичных постов. Все остальные были распознаны, как нейтральные.
Среди этих 5 постов есть те, где я жалуюсь на поликлиники, авиакомпании и классическую русскую литературу. Их правда можно отнести к категории токсичных, так что модель отработала очень хорошо! Показывать эти посты я не буду, ибо стыдно...
Ну а теперь самое весёлое. На просторах Hugging face я нашёл модель apanc/russian-sensitive-topics, которая распознаёт в тексте упоминания различных чувствительных и опасных тем, вроде политики, оружия, расизма и т.п.
Скажу сразу, ни о чём в этом духе я не пишу и все эти темы осуждаю. Единственное, что я мог как-то затронуть в своих текстах, это онлайн-мошенничество и то в исключительно образовательных целях. Однако интересно посмотреть, что скажет модель по поводу моих постов.
Чтобы запустить эту нейросеть нужно скачать с гитхаба разработчиков JSON файл с метками классов, которые модель умеет распознавать. После этого, также следуя инструкциям разработчиков загружаем и применяем модель:
sensitive_model = BertForSequenceClassification.from_pretrained('apanc/russian-sensitive-topics')
sensitive_tokenizer = AutoTokenizer.from_pretrained('apanc/russian-sensitive-topics')
with open("./data/id2topic.json") as f:
target_vaiables_id2topic_dict = json.load(f)
def adjust_multilabel(y):
for y_c in y:
index = str(int(np.argmax(y_c)))
y_c = target_vaiables_id2topic_dict[index]
return y_c
def find_sensitive_topics(text: str) -> str:
tokenized = sensitive_tokenizer.batch_encode_plus([text], max_length = 512,
padding=True,
truncation=True,
return_token_type_ids=False)
tokens_ids,mask = torch.tensor(tokenized['input_ids']), torch.tensor(tokenized['attention_mask'])
with torch.no_grad():
model_output = sensitive_model(tokens_ids,mask)
return adjust_multilabel(model_output['logits'])
df['sensitive_topics'] = df['cleaned_text'].apply(find_sensitive_topics)
И взглянув на value_counts()
данного столбца я довольно сильно удивился... Оказалось, что я собрал солидное такое количество чувствительных тем:
Подавляющее количество постов (497 штук), конечно, были помечены как None т.к модель ничего в них не нашла, но вот пост про лекцию Евгения Касперского в моём универе или все посты, в которых упоминается криптовалюта или NFT нейросеть отметила, как затрагивающие 'online_crime' (22 штуки). Посты про запуск Starship, складные телефоны и плёночную фотографию нейросеть пометила, как затрагивающие тему оружия (Про ракету всё, в общем-то логично. В постах про фотографию фигурируют слова, вроде "затвор", "щелчок" и модель могла так отреагировать на них. А вот чем ей не угодили складные телефоны я не знаю...).
Сильнее всего меня насмешило то, что нейросеть пометила тегом "prostitution" пост с таким текстом: "Кайф, стипендия". Знаете, а ведь в чём-то она даже права...
Конечно, все эти казусы - абсолютно закономерное последствие действительно солидного количества классов, которые умеет распознавать модель. Однако доверять такому алгоритму принятие решений явно ещё не стоит... А то эта нейросеть в моём посте про соревнование на Kaggle нашла упоминание терроризма.
Визуализация эмбеддингов и кластерный анализ
В теме NLP меня сильнее всего впечатляет возможность представить любой текст в числовом формате и выполнять с ним математические операции. И раз мы можем представить тексты в виде чисел, то мы можем и расположить их в числовом пространстве. Именно это я и хочу сделать в данном блоке.
В машинном обучении есть такой термин, как эмбеддинг (англ. embedding). Это векторное представление текста, похожее на bag-of-words (который мы использовали выше для тематического моделирования) или TF-IDF, однако эмбеддинги учитывают не только частоту использования тех или иных слов, но и семантические связи между словами и их смысл. Эмбеддинги получаются благодаря использованию специально обученных нейронных сетей. Для сегодняшней задачи я буду использовать модель LaBSE, которая создаёт очень хорошие эмбеддинги и умеет работать с >100 разных языков, включая русский.
Сначала загрузим её с помощью библиотеки sentence_transformers
и преобразуем наши тексты в эмбеддинги:
import torch
import transformers
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('sentence-transformers/LaBSE')
embeddings = model.encode(sentences)
Эта программа преобразовала все тексты в векторы (последовательности чисел) из 768 составляющих. В целом, уже сейчас хотелось бы поместить эти векторы в числовое пространство и посмотреть, как они расположены относительно друг друга, однако человек пока не может представить себе пространство с 768 измерениями, так что придётся прибегнуть к хитрости: методам понижения размерности.
Довольно часто дата сайнтисты сталкиваются с тем, что приходится работать с векторами высоких размерностей. Это не очень удобно для людей т.к такие векторы невозможно визуализировать и, к тому же, усложняет работу алгоритмов машинного обучения, поскольку некоторые из них чувствительны к размерности данных.
Именно для решения этой проблемы умные математики и инженеры придумали множество методов понижения размерности, которые позволяют с незначительными потерями информации уменьшить размерность вектора. А нам как раз нужно запихнуть вектор из 768 измерений в 3 и для этого мы используем метод umap
:
import umap
n_neighbors = 30
metric = 'cosine'
min_dist = 0.0
random_state = 1210
# Тут сжимаем вектор до 2 измерений
reduser_2d = umap.UMAP(n_components=2,
n_neighbors=n_neighbors,
metric=metric,
min_dist=min_dist,
random_state=random_state)
# А тут до 3
reduser_3d = umap.UMAP(n_components=3,
n_neighbors=n_neighbors,
metric=metric,
min_dist=min_dist,
random_state=random_state)
redused_embeddings_2d = reduser_2d.fit_transform(embeddings)
redused_embeddings_3d = reduser_3d.fit_transform(embeddings)
Вдаваться в подробности работы алгоритма umap и в тонкости настройки параметров я не буду, т.к сам довольно смутно всё это понимаю. Но на хабре есть хорошая статья с картинками которая объясняет на что влияют все эти гиперпараметры. Скажу лишь то, что метрику metric = 'cosine'
мы выбираем из-за того, что работаем с текстовыми данными, а именно косинусное расстояние и отражает близость текстовых эмбеддингов по смыслу.
В коде выше мы уменьшили размерность наших огромных эмбеддингов до 2 и 3 измерений. Сейчас мы, наконец-то можем попробовать поместить их на графики!
Вот так вот распределились посты моего тг-канала в двумерном пространстве. Но ведь скучно, когда все точки одного цвета? Давайте раскрасим их!
Чтобы задать точкам цвета я хочу использовать какой-нибудь алгоритм кластеризации, то есть алгоритм, который самостоятельно изучит все данные, найдёт между объектами что-то общее и объеденит их в группы (кластеры). Для этой задачи я буду использовать классический KMeans:
from sklearn.cluster import KMeans
n_clusters = 5
kmeans_custerizer = KMeans(n_clusters=n_clusters, random_state=1210)
clusters = [f'cluster_{i+1}' for i in range(n_clusters)]
cluster_col = pd.DataFrame(kmeans_custerizer.fit_transform(StandardScaler().fit_transform(embeddings)), columns=clusters).idxmax(axis=1)
Из параметров указываю здесь только количество кластеров, на которые хочу разбить тексты, а также random_state
. В результате я получаю столбец с меткой кластера, к которому принадлежит мой текст.
И вот теперь снова визуализируем эмбеддинги:
import plotly.express as px
vectorized_df_2d = pd.concat([df, pd.DataFrame(redused_embeddings_2d, columns=['x', 'y']), cluster_col], axis=1)
vectorized_df_3d = pd.concat([df, pd.DataFrame(redused_embeddings_3d, columns=['x', 'y', 'z']), cluster_col], axis=1)
vectorized_df_2d.rename(columns={0:'cluster'}, inplace=True)
vectorized_df_3d.rename(columns={0:'cluster'}, inplace=True)
px.scatter(vectorized_df_2d, x='x', y='y', hover_data=['display_text'], color='cluster', title='Визуализация кластеров')
Также, я визуализировал эти тексты в 3D пространстве, чтобы иметь возможность буквально покрутить график и посмотреть на него:
px.scatter_3d(vectorized_df_3d, x='x', y='y', z='z', hover_data=['display_text'], height=700, color='cluster', title='3D Визуализация кластеров')
К сожалению, кластеризация ничего сильно информативного не дала. Тексты раскрасились почти рандомно. Не совсем понимаю с чем это связано... Однако, я смог сам найти несколько кластеров из постов, схожих по темам:
Заключение
Собственно, на этом всё. Я надеюсь, что за время чтения этой статьи вы узнали что-то новое или хотя бы улыбнулись. Если у вас есть замечания, предложения, дополнения - пожалуйста поделитесь ими в комментариях. Я только учусь и мне будет очень полезно услышать любую обратную связь!
Ну и приглашаю вас подписаться на телеграм-канал, который мы с вами сегодня анализировали. Я стараюсь регулярно писать туда о технологиях, искусственном интеллекте и машинном обучении, а также о моём развитии как Data Scientist-а.
Автор: artyom08112006