Привет, Хабр!
Два года назад я создал телеграмм-канал и начал постить туда всякое, что считал интересным. Изначально это было что-то вроде публичного дневника с регулярными и короткими заметками из моей студенческой жизни. После, я попробовал превратить этот канал в блог о современном искусстве, ну а пару месяцев назад понял, что не могу жить без любимой айтишечки и начал постить в канал новости из мира 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 картах… 🤓\n\nТочнее, не совсем на google картах, а на ', {'type': 'text_link', 'text': 'Google Earth.', 'href': '[https://www.google.ru/intl/ru/earth/](https://www.google.ru/intl/ru/earth/)'}, ' Это такой супер-детальный цифровой глобус. \n\nЯ прям искренне наслаждаюсь вот этим чувством исследования и открытия, когда на панорамах какого-то далёкого города нахожу интересное и красивое место или просто смешную подпись 😁 \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'[^\w\s]', 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-а.
