Привет, Хабр!

Два года назад я создал телеграмм-канал и начал постить туда всякое, что считал интересным. Изначально это было что-то вроде публичного дневника с регулярными и короткими заметками из моей студенческой жизни. После, я попробовал превратить этот канал в блог о современном искусстве, ну а пару месяцев назад понял, что не могу жить без любимой айтишечки и начал постить в канал новости из мира 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)
Получившийся DataFrame
Получившийся DataFrame

Полученный 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

Недолго думая, 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 Визуализация кластеров')
3D визуализация
3D визуализация

К сожалению, кластеризация ничего сильно информативного не дала. Тексты раскрасились почти рандомно. Не совсем понимаю с чем это связано... Однако, я смог сам найти несколько кластеров из постов, схожих по темам:

Заключение

Собственно, на этом всё. Я надеюсь, что за время чтения этой статьи вы узнали что-то новое или хотя бы улыбнулись. Если у вас есть замечания, предложения, дополнения - пожалуйста поделитесь ими в комментариях. Я только учусь и мне будет очень полезно услышать любую обратную связь!

Ну и приглашаю вас подписаться на телеграм-канал, который мы с вами сегодня анализировали. Я стараюсь регулярно писать туда о технологиях, искусственном интеллекте и машинном обучении, а также о моём развитии как Data Scientist-а.

Комментарии (5)


  1. SidVisceos
    19.08.2024 21:08
    +3

    "телеграм" пишется с одной "м" на конце.


  1. dyadyaSerezha
    19.08.2024 21:08
    +1

    Хотя сам код примера может быть интересен кому-то, результаты кажутся абсолютно бесполезными. Или я не прав?


    1. artyom08112006 Автор
      19.08.2024 21:08

      Вы абсолютно правы)

      Для меня сутью этого "проекта" было поковыряться в данных и пощупать инструменты для анализа текстовых данных. При этом, никакой реальной бизнес-задачи тут не стояло. В статье я хотел поделиться первым опытом использования некоторых инструментов, таких как LDA или русскоязычные трансформеры, надеясь, что это окажется для кого-то полезным))

      Если у вас есть мысли касательно того, как можно вытянуть из этих текстов более практически-полезную информацию, то я правда буду очень рад выслушать вас и дополнить статью!)


      1. dyadyaSerezha
        19.08.2024 21:08

        Все же в статьях на Хабре лучше делиться не первым, а так примерно. .. 100500-ым опытом. Как и в ТГ-канале)


  1. VSAI
    19.08.2024 21:08
    +1

    Спасибо.