Кластеризация данных Twitter с помощью K-Means, TF-IDF, Word2Vec и Sentence-BERT

Что люди думают и пишут о климате, пандемиях, войне или любой другой животрепещущей проблеме? Подобные вопросы интересны с социологической точки зрения; знание текущих тенденций в мнении людей также может быть интересно для ученых, журналистов или политиков. Но как мы можем получить ответы? Сбор ответов от миллионов людей в прошлом мог быть дорогостоящим процессом, но сегодня мы можем получить эти ответы из сообщений в социальных сетях. В настоящее время доступно множество социальных платформ; Я выбрал Twitter для анализа по нескольким причинам:

  • Твиттер изначально был предназначен для создания коротких постов, которые было бы легче анализировать. По крайней мере, я надеюсь, что при ограничении размера текста люди стараются более лаконично излагать свои мысли.
  • Твиттер — крупная социальная сеть; он был основан почти 20 лет назад. На момент написания этой статьи у него было около 450 миллионов активных пользователей, поэтому легко получить много данных для работы.
  • Twitter имеет официальный API, и его лицензия позволяет нам использовать данные в исследовательских целях.

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

Методология

Наш конвейер обработки данных будет состоять из нескольких шагов.

  • Сбор твитов и сохранение их в файле CSV.
  • Очистка данных.
  • Преобразование текстовых данных в числовую форму. Я буду использовать 3 метода (TF-IDF, Word2Vec и Sentence-BERT) для встраивания текста, и мы посмотрим, какой из них лучше.
  • Кластеризация числовых данных с использованием алгоритма K-средних и анализ результатов. Для визуализации данных я буду использовать методы t-SNE (t-distributed Stochastic Neighbor Embedding), а также мы построим облако слов для наиболее интересных кластеров.

Без дальнейших церемоний, давайте приступим к делу.

1. Загрузка данных

Собрать данные из Twitter несложно. Twitter имеет официальный API и портал для разработчиков; бесплатный аккаунт ограничен одним проектом, этого достаточно для этой задачи. Бесплатная учетная запись позволяет нам получать последние твиты только за последние 7 дней. Я собирал данные в течение месяца, и запустить код раз в неделю не составило труда. Это можно сделать вручную или автоматизировать с помощью Apache Airflow, Cron, GitHub Actions или любого другого инструмента. Если исторические данные действительно нужны, в Твиттере также есть специальная программа доступ к академическим исследованиям.

После бесплатной регистрации на портале мы можем получить ключ и секрет API для нашего проекта. Для доступа к данным я использовал библиотеку Python tweepy. Этот код позволяет нам получить все твиты с хэштегом #climate и сохранить их в CSV-файле:

import tweepy


api_key = "YjKdgxk..."
api_key_secret = "Qa6ZnPs0vdp4X...."

auth = tweepy.OAuth2AppHandler(api_key, api_key_secret)
api = tweepy.API(auth, wait_on_rate_limit=True)

hashtag = "#climate"

def text_filter(s_data: str) -> str:
    """ Remove extra characters from text """
    return s_data.replace("&", "and").replace(";", " ").replace(",", " ") \
                 .replace('"', " ").replace("\n", " ").replace("  ", " ")

def get_hashtags(tweet) -> str:
    """ Parse retweeted data """
    hash_tags = ""
    if 'hashtags' in tweet.entities:
        hash_tags = ','.join(map(lambda x: x["text"], tweet.entities['hashtags']))
    return hash_tags

def get_csv_header() -> str:
    """ CSV header """
    return "id;created_at;user_name;user_location;user_followers_count;user_friends_count;retweets_count;favorites_count;retweet_orig_id;retweet_orig_user;hash_tags;full_text"

def tweet_to_csv(tweet):
    """ Convert a tweet data to the CSV string """
    if not hasattr(tweet, 'retweeted_status'):
        full_text = text_filter(tweet.full_text)
        hasgtags = get_hashtags(tweet)
        retweet_orig_id = ""
        retweet_orig_user = ""
        favs, retweets = tweet.favorite_count, tweet.retweet_count
    else:
        retweet = tweet.retweeted_status
        retweet_orig_id = retweet.id
        retweet_orig_user = retweet.user.screen_name
        full_text = text_filter(retweet.full_text)
        hasgtags = get_hashtags(retweet)
        favs, retweets = retweet.favorite_count, retweet.retweet_count
    s_out = f"{tweet.id};{tweet.created_at};{tweet.user.screen_name};{addr_filter(tweet.user.location)};{tweet.user.followers_count};{tweet.user.friends_count};{retweets};{favs};{retweet_orig_id};{retweet_orig_user};{hasgtags};{full_text}"
    return s_out


pages = tweepy.Cursor(api.search_tweets, q=hashtag, tweet_mode='extended',
                      result_type="recent",
                      count=100,
                      lang="en").pages(limit)

with open("tweets.csv", "a", encoding="utf-8") as f_log:
    f_log.write(get_csv_header() + "\n")
    for ind, page in enumerate(pages):
        for tweet in page:
            # Get data per tweet
            str_line = tweet_to_csv(tweet)
            # Save to CSV
            f_log.write(str_line + "\n")

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

После запуска этого кода я получил около 50 000 твитов с хэштегом «#climate», опубликованных за последние 7 дней.

2. Очистка и трансформация текста

Очистка данных — одна из проблем обработки естественного языка, особенно при разборе постов в социальных сетях. Интересно, что нет «единственно правильного» подхода к этому. Например, хэштеги могут содержать важную информацию, но иногда пользователи просто копируют и вставляют одни и те же хэштеги во все свои сообщения, поэтому релевантность хэштегов для тела сообщения может различаться. Символы эмодзи Unicode также можно очистить, но, возможно, лучше преобразовать их в текст и т. д. После некоторых экспериментов я разработал конвейер преобразования, который, возможно, не идеален, но достаточно хорошо подходит для этой задачи.

URL-адреса и упомянутые имена пользователей

Многие пользователи просто публикуют твиты с URL-адресами, часто без каких-либо комментариев. Приятно сохранить тот факт, что URL-адрес был опубликован, поэтому я преобразовал все URL-адреса в виртуальный тег «#url»:

import re

output = re.sub(r"https?://\S+", "#url", s_text)  # Replace links with '#url'

Пользователи Твиттера часто упоминают в тексте других людей с помощью тега «@». Имена пользователей не имеют отношения к текстовому контексту, и даже более того, такие имена, как «@AngryBeaver2021», только добавляют шума к данным, поэтому я удалил их все:

output = re.sub(r'@\w+', '', output)  # Remove mentioned user names @... 

Хэштеги

Преобразование хэштегов является более сложной задачей. Сначала я преобразовал предложение в токены с помощью NLTK TweetTokenizer:

from nltk.tokenize import TweetTokenizer

s = "This system combines #solar with #wind turbines. #ActOnClimate now. #Capitalism #climate #economics"
tokens = TweetTokenizer().tokenize(s)
print(tokens)
# > ['This', 'system', 'combines', '#solar', 'with', '#wind', 'turbines', '.', '#ActOnClimate', 'now', '.', '#capitalism', '#climate', '#economics']

Это работает, но этого недостаточно. Люди часто используют хэштеги в середине предложения, что-то вроде «важные #новости о климате». В этом случае важно сохранить слово «новости». В то же время пользователи часто добавляют кучу хэштегов в конце каждого сообщения, и в большинстве случаев эти хэштеги просто копируются и вставляются и не имеют прямого отношения к самому тексту. Итак, я решил убрать хэштеги только в конце предложения:

while len(tokens) > 0 and tokens[-1].startswith("#"):
    tokens = tokens[:-1]
# Convert array of tokens back to the phrase
s = ' '.join(tokens)

Это лучше, но все еще недостаточно хорошо. Люди часто объединяют несколько слов в один хэштег, например, «#ActOnClimate» из последнего примера. Мы можем разделить это на три слова:

tag = "#ActOnClimate"
res = re.findall('[A-Z]+[^A-Z]*', tag)
s = ' '.join(res) if len(res) > 0 else tag[1:]
print(s)
# > Act On Climate

Конечным результатом этого шага стала фраза «Эта система объединяет #солнечную энергию с #ветряными турбинами. #ДействуйКлимат сейчас. #Капитализм #климат #экономика» будет преобразовано в «Эта система объединяет #солнечную энергию с #ветряными турбинами. Действуйте в соответствии с климатом сейчас».

Удаление коротких твитов

Многие пользователи часто публикуют изображения или видео, не предоставляя вообще никакого текста. В этом случае тело сообщения почти пусто. Эти сообщения в основном бесполезны для анализа, поэтому я храню в фрейме данных только предложения длиннее 32 символов.

лемматизация

Лемматизация — это процесс приведения слов к их исходной, канонической форме.

import spacy
nlp = spacy.load('en_core_web_sm')

s = "I saw two mice today!"

print(" ".join([token.lemma_ for token in nlp(s)]))
# > I see two mouse today !

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

Этих шагов достаточно, чтобы очистить твиты. Конечно, нет ничего идеального, но для нашей задачи выглядит достаточно хорошо. Для читателей, которые хотели бы провести эксперименты самостоятельно, полный код представлен ниже:

import re
import pandas as pd
from nltk.tokenize import TweetTokenizer

from nltk.corpus import stopwords
stop = set(stopwords.words("english"))

import spacy
nlp = spacy.load('en_core_web_sm')


def remove_stopwords(text) -> str:
    """ Remove stopwords from text """
    filtered_words = [word for word in text.split() if word.lower() not in stop]
    return " ".join(filtered_words)

def expand_hashtag(tag: str):
    """ Convert #HashTag to separated words.
    '#ActOnClimate' => 'Act On Climate'
    '#climate' => 'climate' """
    res = re.findall('[A-Z]+[^A-Z]*', tag)
    return ' '.join(res) if len(res) > 0 else tag[1:]

def expand_hashtags(s: str):
    """ Convert string with hashtags.
    '#ActOnClimate now' => 'Act On Climate now' """
    res = re.findall(r'#\w+', s) 
    s_out = s
    for tag in re.findall(r'#\w+', s):
        s_out = s_out.replace(tag, expand_hashtag(tag))
    return s_out

def remove_last_hashtags(s: str):
    """ Remove all hashtags at the end of the text except #url """
    # "Change in #mind AP #News #Environment" => "Change in #mind AP"
    tokens = TweetTokenizer().tokenize(s)
    # If the URL was added, keep it
    url = "#url" if "#url" in tokens else None
    # Remove hashtags
    while len(tokens) > 0 and tokens[-1].startswith("#"):
        tokens = tokens[:-1]
    # Restore 'url' if it was added
    if url is not None:
        tokens.append(url)
    return ' '.join(tokens) 

def lemmatize(sentence: str) -> str:
    """ Convert all words in sentence to lemmatized form """
    return " ".join([token.lemma_ for token in nlp(sentence)])

def text_clean(s_text: str) -> str:
    """ Text clean """
    try:
        output = re.sub(r"https?://\S+", "#url", s_text)  # Replace hyperlinks with '#url'
        output = re.sub(r'@\w+', '', output)  # Remove mentioned user names @... 
        output = remove_last_hashtags(output)  # Remove hashtags from the end of a string
        output = expand_hashtags(output)  # Expand hashtags to words
        output = re.sub("[^a-zA-Z]+", " ", output) # Filter
        output = re.sub(r"\s+", " ", output)  # Remove multiple spaces
        output = remove_stopwords(output)  # Remove stopwords
        return output.lower().strip()
    except:
        return ""

def text_len(s_text: str) -> int:
    """ Length of the text """
    return len(s_text)


df = pd.read_csv("tweets.csv", sep=';', dtype={'id': object, 'retweet_orig_id': object, 'full_text': str, 'hash_tags': str}, lineterminator='\n')
df['text_clean'] = df['full_text'].map(text_clean)

df['text_len'] = df['text_clean'].map(text_len)
df = df[df['text_len'] > 32]

display(df)

В качестве бонуса, с чистым текстом мы можем легко нарисовать облако слов:

from wordcloud import WordCloud
import matplotlib.pyplot as plt  

def draw_cloud(column: pd.Series, stopwords=None):
    all_words = ' '.join([text for text in column]) 
    
    wordcloud = WordCloud(width=1600, height=1200, random_state=21, max_font_size=110, collocations=False, stopwords=stopwords).generate(all_words) 
    plt.figure(figsize=(16, 12)) 
    plt.imshow(wordcloud, interpolation="bilinear") 
    plt.axis('off')
    plt.show()
    
    
draw_cloud(df['text_clean'])

Результат выглядит следующим образом:

Это еще не настоящий анализ, но это изображение уже может дать некоторое представление о том, что люди пишут о климате. Например, мы видим, что люди часто размещают ссылки («URL» — самое большое слово в облаке), и такие слова, как «энергия», «отходы», «ископаемое» или «кризис», также актуальны и важны.

3. Векторизация

Векторизация текста — это процесс преобразования текстовых данных в числовое представление. Большинство алгоритмов, включая кластеризацию K-средних, требуют векторов, а не обычного текста. И само преобразование не простое. Задача состоит не только в том, чтобы каким-то образом присвоить всем словам несколько случайных векторов; в идеале преобразование слов в вектор должно сохранять взаимосвязь между этими словами в исходном языке.

Я протестирую три разных подхода, и мы увидим преимущества и недостатки каждого из них.

TF-IDF

TF-IDF (Term Frequency-Inverse Document Frequency) — довольно старый алгоритм; функция взвешивания терминов, известная как IDF, уже была предложена в 1970-х годах. Результат TF-IDF основан на числовой статистике, где TF (частота терминов) — это количество раз, когда слово появлялось в документе (в нашем случае, в твите), а IDF (обратная частота документа) показывает, как часто одно и то же слово появляется в текстовом корпусе (полном наборе документов). Чем выше оценка конкретного слова, тем важнее это слово в конкретном твите.

Перед обработкой реального набора данных рассмотрим игрушечный пример. Два очищенных от стоп-слов твита, первый о климате, а второй о кошках:

from sklearn.feature_extraction.text import TfidfVectorizer

docs = ["climate change . information about climate important", 
        "my cat cute . love cat"]

tfidf = TfidfVectorizer()
vectorized_docs = tfidf.fit_transform(docs).todense()

print("Shape:", vectorized_docs.shape)
display(pd.DataFrame(vectorized_docs, columns=tfidf.get_feature_names_out()))

Результат выглядит следующим образом:

Как видим, из двух твитов мы получили два вектора. Каждая цифра в векторе пропорциональна «важности» слова в конкретном твите. Например, слово «климат» в первом твите повторилось дважды. Там у него высокое значение, а во втором твите нулевое значение (и, очевидно, вывод для слова «кошка» противоположный).

Давайте попробуем тот же подход на реальном наборе данных, который мы собрали ранее:

docs = df["text_clean"].values

tfidf = TfidfVectorizer()
vectorized_docs = np.asarray(tfidf.fit_transform(docs).todense())

print("Shape:", vectorized_docs.shape)
# > Shape: (19197, 22735)

TfidfVectorizer сделал свое дело; он преобразовал каждый твит в вектор. Размерность векторов равна общему количеству слов в корпусе, что довольно много. В моем случае 19 197 твитов имеют 22 735 уникальных токенов, и на выходе я получил матрицу формы 19 197x22 735! Использование такой матрицы может оказаться сложной задачей даже для современных компьютеров.

Мы кластеризуем эти данные на следующем шаге, но перед этим давайте протестируем другие методы векторизации.

Word2Vec

Word2Vec — еще один подход к векторизации слов; первая статья об этом методе была представлена ​​Томашем Миколовым в 2013 году в Google. В реализации доступны разные алгоритмы (модели Skip-gram и CBOW); общая идея состоит в том, чтобы обучить модель на большом текстовом корпусе и получить точное преобразование слова в вектор. Эта модель способна изучать отношения между разными словами, как показано в оригинальной статье:

Вероятно, самый известный пример использования этой модели — отношения между словами король, мужчина и королева. Те, кому интересны подробности, могут прочитать эту красивую статью.

Для нашей задачи я буду использовать файл предварительно обученный вектор. Эта модель была обучена с использованием набора данных Google News; файл содержит векторы для 3 миллионов слов и фраз. Прежде чем использовать реальный набор данных, давайте снова рассмотрим игрушечный пример:

from gensim.models import Word2Vec, KeyedVectors
from gensim.models.doc2vec import Doc2Vec, TaggedDocument


word_vectors = KeyedVectors.load_word2vec_format('GoogleNews-vectors-negative300.bin', binary=True)

print("Shape:", word_vectors["climate"].shape)
display(word_vectors["climate"])

Результат выглядит следующим образом:

Как мы видим, слово «климат» было преобразовано в массив длины из 300 цифр.

Используя Word2Vec, мы можем получить вложения для каждого слова, но нам нужно вложение для всего твита. В качестве самого простого подхода мы можем использовать арифметику встраивания слов и получить среднее значение всех векторов:

from nltk import word_tokenize


def word2vec_vectorize(text: str):
    """ Convert text document to the embedding vector """    
    vectors = []
    tokens = word_tokenize(text)
    for token in tokens:
        if token in word_vectors:
            vectors.append(word_vectors[token])
            
    return np.asarray(vectors).mean(axis=0) if len(vectors) > 0 else np.zeros(word_vectors.vector_size)

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

docs = df["text_clean"].values

vectorized_docs = list(map(word2vec_vectorize, docs))
print("Shape:", vectorized_docs.shape)

# > Shape: (22535, 300)

Как мы видим, вывод Word2Vec гораздо более эффективно использует память по сравнению с подходом TF-IDF. У нас есть 300-мерные векторы для каждого твита, а форма выходной матрицы составляет 19 197 x 300 вместо 19 197 x 22 735 — разница в объеме памяти в 75 раз!

Doc2Vec — еще одна модель, которая может быть более эффективной для встраивания документов по сравнению с наивным усреднением; он был специально разработан для векторного представления документов. Но на момент написания этой статьи мне не удалось найти предварительно обученную модель Doc2Vec. Читатели могут попробовать это самостоятельно.

Приговор-BERT

На предыдущем шаге мы получили встраивание слов с помощью Word2Vec. Это работает, но у этого подхода есть очевидный недостаток. Word2Vec не учитывает контекст слова; например, слово банк в предложении берег реки получит то же вложение, что и банк Англии. Чтобы исправить это и получить более точные вложения, мы можем попробовать другой подход. Языковая модель BERT (представления двунаправленного кодировщика от Transformer) была представлена ​​в 2018 году. Он был обучен на замаскированных текстовых предложениях, в которых положение и контекст каждого слова действительно имеют значение. BERT изначально не создавался для расчета эмбеддингов, но оказалось, что извлечение эмбеддингов из слоев BERT — эффективный подход (подробнее можно в тех статьях TDS от 2019 и 2020: 1, 2).

В настоящее время, спустя несколько лет, ситуация улучшилась, и нам не нужно вручную извлекать необработанные вложения из BERT; специальные проекты типа Трансформеры предложений были специально разработаны для этого. Перед обработкой реального набора данных рассмотрим игрушечный пример:

from sentence_transformers import SentenceTransformer


docs = ['the influence of human activity on the warming of the climate system has evolved from theory to established fact', 
        'cats can jump 5 times their own height']

model = SentenceTransformer('all-MiniLM-L6-v2')
vectorized_docs = model.encode(np.asarray(docs))

print("Shape:", vectorized_docs.shape)
# > Shape: (2, 384)

На выходе мы получили два 384-мерных вектора для наших предложений. Как видим, пользоваться моделью несложно, даже удаление стоп-слов не требуется; библиотека делает все это автоматически.

Давайте теперь получим вложения для наших твитов. Поскольку встраивания слов BERT чувствительны к контексту слова, а библиотека имеет собственную очистку и токенизацию, я не буду использовать столбец «text_clean», как раньше. Вместо этого я буду преобразовывать в текст только URL-адреса твитов и хэштеги. Метод «partial_clean» использует части кода из оригинальной функции «text_clean», использованной в начале этой статьи:

def partial_clean(s_text: str) -> str:
    """ Convert tweet to a plain text sentence """
    output = re.sub(r"https?://\S+", "#url", s_text)  # Replace hyperlinks with '#url'
    output = re.sub(r'@\w+', '', output)  # Remove mentioned user names @... 
    output = remove_last_hashtags(output)  # Remove hashtags from the end of a string
    output = expand_hashtags(output)  # Expand hashtags to words
    output = re.sub(r"\s+", " ", output)  # Remove multiple spaces
    return output


docs = df['full_text'].map(partial_clean).values
vectorized_docs = model.encode(np.asarray(docs))
print("Shape:", vectorized_docs.shape)

# > Shape: (19197, 384)

На выходе преобразователя предложений мы получили массив размерностью 19 197x384.

В качестве примечания важно отметить, что модель BERT гораздо более «тяжелая» в вычислительном отношении по сравнению с Word2Vec. Вычисление векторов для 19 197 твитов заняло около 80 секунд на 12-ядерном процессоре, по сравнению с 1,8 секундами, необходимыми для Word2Vec. Это не проблема для проведения подобных тестов, но их использование в облачной среде может быть более дорогим.

4. Кластеризация и визуализация

Наконец, мы подошли к последней части этой статьи. На предыдущих шагах мы получили 3 версии массива vectorized_docs, сгенерированные 3 методами: TF-IDF, Word2Vec и Sentence-BERT. Давайте объединим эти вложения в группы и посмотрим, какую информацию мы можем извлечь.

Для этого сначала создадим несколько вспомогательных функций:

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


def make_clustered_dataframe(x: np.array, k: int) -> pd.DataFrame:
    """ Create a new dataframe with original docs and assigned clusters """
    ids = df["id"].values
    user_names = df["user_name"].values
    docs = df["text_clean"].values
    tokenized_docs = df["text_clean"].map(text_to_tokens).values
    
    km = KMeans(n_clusters=k).fit(x)
    s_score = silhouette_score(x, km.labels_)
    print(f"K={k}: Silhouette coefficient {s_score:0.2f}, inertia:{km.inertia_}")
    
    # Create new DataFrame
    data_len = x.shape[0]
    df_clusters = pd.DataFrame({
        "id": ids[:data_len],
        "user": user_names[:data_len],
        "text": docs[:data_len],
        "tokens": tokenized_docs[:data_len],
        "cluster": km.labels_,
    })
    return df_clusters


def text_to_tokens(text: str) -> List[str]:
    """ Generate tokens from the sentence """
    # "this is text" => ['this', 'is' 'text']
    tokens = word_tokenize(text)  # Get tokens from text
    tokens = [t for t in tokens if len(t) > 1]  # Remove short tokens
    return tokens


# Make clustered dataframe
k = 30
df_clusters = make_clustered_dataframe(vectorized_docs, k)
with pd.option_context('display.max_colwidth', None):
    display(df_clusters)

Я использую SciKit-learn KMeans для создания кластеризации K-Means. Метод make_clustered_dataframe создает фрейм данных с исходными твитами и новым столбцом кластер. При использовании K-Means у нас также есть две метрики, которые помогают нам оценивать результаты. Инерция может использоваться для измерения качества кластеризации. Он рассчитывается путем измерения расстояния между всеми точками кластера и центроидами кластера, и чем меньше значение, тем лучше. Еще одна полезная метрика – оценка силуэта; это значение имеет диапазон [-1, 1]. Если значение близко к 1, кластеры хорошо разделены; если значение около 0, расстояние не имеет значения; и если значения отрицательные, кластеры перекрываются.

Вывод make_clustered_dataframe выглядит следующим образом:

Это работает, но только используя эту информацию, трудно понять, достаточно ли хороши кластеры. Давайте добавим еще один вспомогательный метод для отображения лучших кластеров, отсортированных по показателю силуэта. Для расчета я использую метод SciKit-learn Silhouette_samples. Я также буду использовать облако слов для визуализации каждого кластера:

from sklearn.metrics import silhouette_samples


def show_clusters_info(x: np.array, k: int, cdf: pd.DataFrame):
    """ Print clusters info and top clusters """
    labels = cdf["cluster"].values
    sample_silhouette_values = silhouette_samples(x, labels)
    
    # Get silhouette values per cluster    
    silhouette_values = []
    for i in range(k):
        cluster_values = sample_silhouette_values[labels == i]
        silhouette_values.append((i, 
                                  cluster_values.shape[0], 
                                  cluster_values.mean(), 
                                  cluster_values.min(), 
                                  cluster_values.max()))
    # Sort
    silhouette_values = sorted(silhouette_values, 
                               key=lambda tup: tup[2], 
                               reverse=True)
    
    # Show clusters, sorted by silhouette values
    for s in silhouette_values:
        print(f"Cluster {s[0]}: Size:{s[1]}, avg:{s[2]:.2f}, min:{s[3]:.2f}, max: {s[4]:.2f}")

    # Show top 7 clusters
    top_clusters = []
    for cl in silhouette_values[:7]:
        df_c = cdf[cdf['cluster'] == cl[0]]

        # Show cluster
        with pd.option_context('display.max_colwidth', None):
            display(df_c[["id", "user", "text", "cluster"]])
            
        # Show words cloud
        s_all = ""
        for tokens_list in df_c['tokens'].values:
            s_all += ' '.join([text for text in tokens_list]) + " "            
        draw_cloud_from_words(s_all, stopwords=["url"])
        
        # Show most popular words
        vocab = Counter()
        for token in df_c["tokens"].values:
            vocab.update(token)
        display(vocab.most_common(10))


def draw_cloud_from_words(all_words: str, stopwords=None):
    """ Show the word cloud from the list of words """
    wordcloud = WordCloud(width=1600, height=1200, random_state=21, max_font_size=110, collocations=False, stopwords=stopwords).generate(all_words) 
    plt.figure(figsize=(16, 12)) 
    plt.imshow(wordcloud, interpolation="bilinear") 
    plt.axis('off')
    plt.show()


show_clusters_info(vectorized_docs, k, df_clusters)

Следующий важный вопрос перед использованием метода K-Means — это выбор K, оптимального количества кластеров. Метод локтя — популярная техника; идея состоит в том, чтобы построить график значения инерции для различных значений K. Точка локтя на графике — это (по крайней мере, теоретически) значение оптимального K. На практике он редко работает так, как ожидалось, особенно для плохо структурированных наборов данных, таких как векторизованные твиты, но график может дать некоторую информацию. Создадим вспомогательный метод для рисования графика локтя:

import matplotlib.pyplot as plt  
%matplotlib inline


def graw_elbow_graph(x: np.array, k1: int, k2: int, k3: int):
    k_values, inertia_values = [], []
    for k in range(k1, k2, k3):
        print("Processing:", k)
        km = KMeans(n_clusters=k).fit(x)
        k_values.append(k)
        inertia_values.append(km.inertia_)

    plt.figure(figsize=(12,4))
    plt.plot(k_values, inertia_values, 'o')
    plt.title('Inertia for each K')
    plt.xlabel('K')
    plt.ylabel('Inertia')


graw_elbow_graph(vectorized_docs, 2, 50, 2)

Визуализация

В качестве бонуса давайте добавим последний (обещаю, это последний :) вспомогательный метод для рисования всех кластеров на 2D-плоскости. Я полагаю, что большинство читателей еще не могут визуализировать 300-мерные векторы в уме;) поэтому я буду использовать методы уменьшения размерности t-SNE (T-распределенное стохастическое соседнее встраивание), чтобы уменьшить количество измерений до 2, и Боке, чтобы отрисовать результаты:

from sklearn.decomposition import PCA
from sklearn.manifold import TSNE

from bokeh.io import show, output_notebook, export_png
from bokeh.plotting import figure, output_file
from bokeh.models import ColumnDataSource, LabelSet, Label, Whisker, FactorRange
from bokeh.transform import factor_cmap, factor_mark, cumsum
from bokeh.palettes import *
from bokeh.layouts import row, column
output_notebook()


def draw_clusters_tsne(docs: List, cdf: pd.DataFrame):
    """ Draw clusters using TSNE """
    cluster_labels = cdf["cluster"].values
    cluster_names = [str(c) for c in cluster_labels]
    
    tsne = TSNE(n_components=2, verbose=1, perplexity=50, n_iter=300, 
                init='pca', learning_rate='auto')
    tsne_results = tsne.fit_transform(vectorized_docs)

    # Plot output
    x, y = tsne_results[:, 0], tsne_results[:, 1]
    source = ColumnDataSource(dict(x=x, 
                                   y=y, 
                                   labels=cluster_labels,
                                   colors=cluster_names))
    palette = (RdYlBu11 + BrBG11 + Viridis11 + Plasma11 + Cividis11 + RdGy11)[:len(cluster_names)]

    p = figure(width=1600, height=900, title="")
    p.scatter("x", "y",
              source=source, fill_alpha=0.8, size=4,
              legend_group='labels',
              color=factor_cmap('colors', palette, cluster_names)
              )
    show(p)
    

draw_clusters_tsne(vectorized_docs, df_clusters)

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

Полученные результаты

Я использовал три разных алгоритма (TF-IDF, Word2Vec и Sentence-BERT) для преобразования текста в векторы встраивания, которые серьезно отличаются по архитектуре. Смогут ли все они найти интересные закономерности во всех твитах? Давайте рассмотрим результаты.

TF-IDF

Основным недостатком поиска кластеров во вложениях TF-IDF является большой объем данных. В моем случае размер матрицы был 19 197x22 735, потому что текстовый корпус содержит 19 197 твитов и 22 735 уникальных токенов. Поиск кластеров в матрице такого размера дело не быстрое даже для современного ПК.

В общем, векторизация TF-IDF не дала исключительных результатов, но K-Means все же смог найти несколько интересных кластеров. Например, из всех 19 197 твитов был обнаружен кластер из 200 твитов, в котором люди делали посты о международном онлайн-форуме:

K-Means также смог найти некоторых пользователей, которые сделали много похожих постов:

В данном случае пользователь с ником «**mickel», вероятно, пытался продвигать свою онлайн-книгу (кстати, показ id сообщения полезен для отладки; мы всегда можем открыть исходный твит в браузере), и он опубликовал много подобных сообщений об этом. Эти сообщения не были абсолютно похожи, но алгоритм смог сгруппировать их вместе. Такой подход может быть полезен, например, при обнаружении учетных записей, используемых для рассылки спама.

Некоторые интересные кластеры были обнаружены в векторах TF-IDF, но большинство других кластеров имели значения силуэта около нуля. Визуализация t-SNE показывает тот же результат. На картинке есть несколько локальных групп, но большинство точек перекрывают друг друга:

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

Word2Vec

Первый интересный результат с Word2Vec — метод Elbow смог создать видимую точку «локтя»:

При K=8 визуализация t-SNE дала такой результат:

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

"Изменение климата". В этом кластере самые популярные слова «климат», «изменение», «действие» и «глобальный»:

Очевидно, что многие люди обеспокоены изменением климата, поэтому наличие этого кластера очевидно.

«Топливо». В этом кластере есть популярные слова, такие как «энергия», «углерод», «излучение», «ископаемое» или «солнечная энергия»:

"Среда". Здесь мы можем встретить такие слова, как «температура», «океан», «море», «лед» и так далее.

Приговор-BERT

Теоретически эти вложения должны давать наиболее точные результаты; посмотрим как пойдет. Визуализация кластера t-SNE выглядит следующим образом:

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

"Таяние льда". Кластер с самыми популярными словами «климат», «лед», «таяние», «ледник» и «арктика»:

"День Земли". Этот день отмечается в апреле, когда были собраны эти данные, и есть группа сообщений со словами вроде «земля», «день», «планета», «счастливый» или «действие»:

«Глобальный международный форум»:

Этот результат интересен по двум причинам. Во-первых, мы видели это скопление раньше; алгоритм K-Means нашел его во вложениях TF-IDF. Во-вторых, в модели «Word2Vec» не было слова «thereisa» в словаре, поэтому оно было просто пропущено. BERT имеет лучшую схему токенизации, в которой неизвестные слова разбиваются на более мелкие токены. Мы можем легко увидеть, как это работает:

model = SentenceTransformer('all-MiniLM-L6-v2')

inputs = model.tokenizer(["thereisa online forum"])
tokens = [e.tokens for e in inputs.encodings]

print(tokens)
# > [['[CLS]', 'there', '##isa', 'online', 'forum', '[SEP]']]

Мы видим, что слова «онлайн» и «форум» были преобразованы в отдельные токены, а слово «тереса» — в два слова «там» и «##иса». Это не только позволяет BERT работать с неизвестными словами, но на самом деле это намного ближе к тому, что мы, люди, часто делаем: когда мы видим неизвестные слова, мы часто пытаемся «разбить» их на части и угадать значение.

Но пойдем дальше. Другая отдельная группа связана с протестами; мы можем увидеть здесь такие слова, как «протест», «изменение», «действие», «активизм» и так далее:

И последнее, но не менее важное: еще одна популярная тема, связанная с климатом, — электротранспорт. Здесь мы можем увидеть такие слова, как «новый», «электрический», «автомобиль» или «выбросы»:

Заключение

Используя текстовую кластеризацию, мы смогли обработать необработанный и неструктурированный текст из социальных сетей (в нашем случае Twitter, но этот подход должен работать и с другими платформами) и найти интересные и характерные закономерности в постах десятков тысяч пользователей. Это может быть важно не только по чисто академическим причинам, таким как исследования культурной антропологии или социологии, но и для «прагматических» случаев, таких как обнаружение ботов или пользователей, публикующих спам.

С точки зрения обработки естественного языка кластеризация данных социальных сетей — интересная и сложная тема. Это сложно, потому что существует множество способов очистки и преобразования данных, и ни один из них не будет идеальным. В нашем случае внедрение BERT неудивительно дало лучшие результаты по сравнению с более ранними моделями TF-IDF и Word2Vec. BERT не только дает хорошие результаты, но и лучше справляется с неизвестными словами, что может быть проблемой для Word2Vec. Встраивания TF-IDF, на мой взгляд, не показали каких-то выдающихся результатов, но преимущество у этого подхода все же есть. TF-IDF основан на чистой статистике и не требует предварительно обученной языковой модели. Итак, в случае редких языков, для которых нет предварительно обученных моделей, можно использовать TF-IDF.

В этой статье представлен своего рода низкоуровневый подход, который лучше подходит для понимания того, как все работает, и я призываю читателей провести некоторые эксперименты самостоятельно; исходного кода в статье должно быть достаточно для этого. В то же время, те, кто просто хочет получить результат в 10 строк кода, не задумываясь о том, что находится под капотом, могут попробовать готовые библиотеки, такие как BERTopic.

Если вам понравилась эта история, смело подписывайтесь на Medium, и вы будете получать уведомления о выходе моих новых статей, а также полный доступ к тысячам историй других авторов.

Спасибо за прочтение.