Анализ негативных комментариев TRUE CRIME

от автора

Привет! Я тут активно пытаюсь охватить разные области в сфере Data Science и решила, что было бы классно покопаться c обработкой естественного языка (NLP) на примере комментариев YouTube. Так как после работы я часто смотрю видео Саши Сулим, я задалась вопросом: «Интересно, а есть ли различия в оценке зрителями видео про маньяков в зависимости от пола!? Или нам не важно, кто был убийцей — мужчина/женщина?»

Так я пришла к тому, что могу взять задачку классификации комментариев по оценке их негативности в качестве pet-проекта. То, насколько это получилось, предлагаю оценить вам.

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

Dataset

Для обучения мною был выбран датасет с Kaggle из комментариев, собранных с сайта 2ch.hk и pikabu.ru. Среднестатистический комментарий имеет длину 175 символов, минимальная длина комментария — 21 символ, максимальная — 7 403.

EDA (Exploratory Data Analysis)

Для начала посмотрим что из себя представляет наш датасет. Для этого проведем стандартный анализ:

df = pd.read_csv("./data/labeled.csv", sep=',') df.shape >>> (14412, 2)  # преобразуем значения колонки «toxic» к типу (int) для удобства df["toxic"] = df["toxic"].apply(int)  df["toxic"].value_counts() >>> 0    9586 >>> 1    4826  # проверим, что нет пустых значений df[df["toxic"] == 0]["comment"].isna().sum() >>> 0

Итак, мы выяснили, что датасет представляем собой 14 412 комментариев. Распределение в данном наборе следующее: 4 826 — негативные, 9 586 — нейтральные.

Text preprocessing

Любые сырые данные нужно предобаботать. Для этого есть несколько важных этапов: токенизация, удаление пунктуации и стоп-слов, а также стемминг. Давайте приступим!

# возьмем для примера один комментарий example = df.iloc[1]["comment"] print(f"Исходный текст: {example}") >>> Исходный текст: Хохлы, это отдушина затюканого россиянина, мол, вон, а у хохлов еще хуже. Если бы хохлов не было, кисель их бы придумал.  # разобьем на токены tokens = word_tokenize(example, language="russian") print(f"Токены: {tokens}") >>> Токены: ['Хохлы', ',', 'это', 'отдушина', 'затюканого', 'россиянина', ',', 'мол', ',', 'вон', ',', 'а', 'у', 'хохлов', 'еще', 'хуже', '.', 'Если', 'бы', 'хохлов', 'не', 'было', ',', 'кисель', 'их', 'бы', 'придумал', '.']  # уберем всю пунктуацию и стоп-слова tokens_without_punct = [i for i in tokens if i not in string.punctuation] stop_words = stopwords.words("russian") print(f"Токены без пунктуации: {tokens_without_punct}") print(f"Токены без пунктуации и стоп слов: {tokens_without_punct_and_stopwords}") >>> Токены без пунктуации: ['Хохлы', 'это', 'отдушина', 'затюканого', 'россиянина', 'мол', 'вон', 'а', 'у', 'хохлов', 'еще', 'хуже', 'Если', 'бы', 'хохлов', 'не', 'было', 'кисель', 'их', 'бы', 'придумал'] >>> Токены без пунктуации и стоп слов: ['Хохлы', 'это', 'отдушина', 'затюканого', 'россиянина', 'мол', 'вон', 'хохлов', 'хуже', 'Если', 'хохлов', 'кисель', 'придумал']  # далее Стемминг - процесс приведения слов к их базовой/корневой форме.  tokens_without_punct_and_stopwords = [i for i in tokens_without_punct if i not in stop_words] snowball = SnowballStemmer(language="russian") stemmed_tokens = [snowball.stem(i) for i in tokens_without_punct_and_stopwords] print(f"Токены после стемминга: {stemmed_tokens}") >>> Токены после стемминга: ['хохл', 'эт', 'отдушин', 'затюкан', 'россиянин', 'мол', 'вон', 'хохл', 'хуж', 'есл', 'хохл', 'кисел', 'придума']

Так как процесс предобработки будет повторяться — создадим для удобства функцию, повторяющую все вышеперечисленные преобразования.

snowball = SnowballStemmer(language="russian") russian_stop_words = stopwords.words("russian")  def tokenize_sentence(sentence: str, remove_stop_words: bool = True):     tokens = word_tokenize(sentence, language="russian")     tokens = [i for i in tokens if i not in string.punctuation]     if remove_stop_words:         tokens = [i for i in tokens if i not in russian_stop_words]     tokens = [snowball.stem(i) for i in tokens]     return tokens

Отлично, теперь разделим наш датасет на обучающую и тестовую выборку и сравним их распределение.

train_df, test_df = train_test_split(df, test_size = 500, random_state=234) print(train_df.shape) print(test_df.shape) >>> (13912, 2) >>> (500, 2)  # сравним распределение целевого признака for sample in [train_df, test_df]:     print(sample[sample['toxic'] == 1].shape[0] / sample.shape[0]) >>> 0.3356095457159287 >>> 0.314

Получили распределение:

Обучающая выборка

33.56% токсичных комментариев

Тестовая выборка

31.4% токсичных комментариев

Данные равномерно распределены по выборкам, следовательно наша будущая модель должна адекватно оцениваться на тестовых данных.

TF-IDF

Прежде чем приступить к обучению нашей модели мы должны преобразовать наши комментарии в численные массивы. Для этого воспользуемся TF-IDF векторизацией.

TF измеряет насколько часто термин (слово) встречается в документе. Формула для расчета TF:

\text{TF}(t, d) = \frac{f(t, d)}{N_d}

где f(t,d) — количество вхождений термина t в документ d , а Nd — общее количество терминов в документе d.

IDF измеряет важность термина по отношению ко всему корпусу документов. Чем реже термин встречается в корпусе, тем выше его IDF. Формула для расчета IDF:

 \text{IDF}(t, D) = \log \left( \frac{N}{\left|\{d \in D : t \in d\}\right|} \right)

где N — общее количество документов в корпусе D, а ∣{d∈D:t∈d}∣ — количество документов, содержащих термин t.

TF-IDF объединяет TF и IDF для оценки важности термина в конкретном документе. Формула для расчета TF-IDF:

 \text{TF-IDF}(t, d, D) = \text{TF}(t, d) \times \text{IDF}(t, D)

Для использования TF-IDF применим библиотеку scikit-learn

# инициализируем векторайзер и применим к нашим выборкам count_idf_1 = TfidfVectorizer(ngram_range = (1,1), tokenizer=lambda x: tokenize_sentence(x, remove_stop_words=True)) tf_idf_base_1 = count_idf_1.fit(df['comment']) tf_idf_train_base_1 = count_idf_1.transform(train_df['comment']) tf_idf_test_base_1 = count_idf_1.transform(test_df['comment'])  # выведем размеры матриц, чтобы убедиться в корректности: print(tf_idf_train_base_1.shape) print(tf_idf_test_base_1.shape) >>> (13912, 36122) >>> (500, 36122)

Для примера давайте рассмотрим как происходит TF-IDF на одном из комментариев.

sample = test_df.sample(n=1)['comment'] sample_tf_idf = count_idf_1.transform(sample) sample_tf_idf.shape >>> (1, 36122)  array = sample_tf_idf.toarray() array >>> array([[0., 0., 0., ..., 0., 0., 0.]])  # как выглядит наш комментарий до векторизации sample >>> 12391    Что касается 3 млн, у Кия самая дорогая машина...  # извлекаем и выводим ненулевые элементы, которые соответствуют значимым словам: array[array!= 0] >>> array([0.27552192, 0.25845753, 0.24785363, 0.19574676, 0.13724815,            0.25845753, 0.13854953, 0.21636683, 0.18436214, 0.2040751 ,            0.25845753, 0.23449431, 0.13459448, 0.37887959, 0.20099479,            0.14063173, 0.15832929, 0.10074052, 0.11669742, 0.25845753,            0.25845753, 0.06473031]) 

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

Обучение модели

В качестве baseline я использовала логистическую регрессию, т.к она хорошо подходит для задачи бинарной классификации.

Если вы еще не знакомы с данной моделью, но уже слышали про линейную регрессию, то можно сказать, что вы почти знаток. Дело в том, что логистическая регрессия по сути это линейная регрессия, к результату которой в конце применяется логистическая функция (например, сигмоида).

Формула сигмоидной функции:

\sigma(z) = \frac{1}{1 + e^{-z}}

где z— линейная комбинация признаков и их весов: z = β01x12x2+…+βnxn.

Значение σ(z) лежит между 0 и 1, что интерпретируется как вероятность.

# инициализируем модель model_lr_base_1 = LogisticRegression(solver='lbfgs', random_state=234, max_iter= 10000, n_jobs= -1)  # обучим модель model_lr_base_1.fit(tf_idf_train_base_1, train_df['toxic'])  # получим прогноз вероятностей классов predict_lr_base_proba = model_lr_base_1.predict_proba(tf_idf_test_base_1) predict_lr_base_proba >>> array([[0.85603587, 0.14396413],            [0.29448938, 0.70551062],            [0.41543358, 0.58456642],            [0.77011541, 0.22988459],            [0.62820949, 0.37179051],            ...            [0.82299013, 0.17700987]])

Каждая строка predict_lr_base_proba представляет собой пару чисел: вероятность не токсичного комментария (первое число) и вероятность токсичного комментария (второе число) соответственно.

Оценка модели

Предлагаю еще сравнить качество нашей модели с случайным классификатором.

def coin_classifier(X:np.array) -> np.array:     predict = np.random.uniform(0.0, 1.0, X.shape[0])     return predict coin_predict = coin_classifier(tf_idf_test_base_1)

Визуализируем ROC-кривые и выведем матрицу ошибок.

# для нашей модели логистической регрессии fpr_base, tpr_base, _ = roc_curve(test_df['toxic'], predict_lr_base_proba[:, 1]) roc_auc_base = auc(fpr_base, tpr_base)  # для случайного классификатора  fpr_coin, tpr_coin, _ = roc_curve(test_df['toxic'], coin_predict) roc_auc_coin = auc(fpr_base, tpr_base)  fig = make_subplots(1,1,                     subplot_titles = ["Receiver operating characteristic"],                     x_title="False Positive Rate",                     y_title = "True Positive Rate"                    ) fig.add_trace(go.Scatter(     x = fpr_base,     y = tpr_base,     #fill = 'tozeroy',     name = "ROC base (area = %0.3f)" % roc_auc_base,     )) fig.add_trace(go.Scatter(     x = fpr_coin,     y = tpr_coin,     mode = 'lines',     line = dict(dash = 'dash'),     name = 'Coin classifier (area = 0.5)'     )) fig.update_layout(     height = 600,     width = 800,     xaxis_showgrid=False,     xaxis_zeroline=False,     template = 'plotly_dark',     font_color = 'rgba(212, 210, 210, 1)'     )  # матрица ошибок confusion_matrix(test_df['toxic'],                  (predict_lr_base_proba[:, 1] > 0.5).astype('float'),                  normalize='true',                 ) >>> array([[0.97959184, 0.02040816],        [0.35031847, 0.64968153]]) 
  • AUC случайного классификатора близок к 0.5, что свидетельствует о том, что этот классификатор неспособен эффективно различать классы.

  • Модель логистической регрессии показывает значительно лучшие результаты по сравнению с случайным классификатором, что подтверждает ее ценность в задаче классификации комментариев.

Парсинг комментариев

Наконец, перейдем к заключительной части — к нашим комментариям под видео Саши Сулим! Давайте для начала спарсим все комментарии с видео про женщин-маньяков.

# инициализируем Chrome WebDriver с использованием chromedriver-py driver = webdriver.Chrome(executable_path=binary_path)  # создаем список для результатов парсинга scrapped = []  # указываем время ожидания в секундах и URL видео wait = WebDriverWait(driver, 10) driver.get("https://www.youtube.com/watch?v=Bru4DtUe_CE&t=4s")  # задаем количество прокруток для загрузки комментариев for item in tqdm(range(200)):     wait.until(EC.visibility_of_element_located((By.TAG_NAME, "body"))).send_keys(Keys.END)     time.sleep(2)  # получаем комментарии по тэгу "#content" for comment in wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, "#content"))):     scrapped.append(comment.text)  # Закрываем браузер driver.quit()

Теперь отчистим комментарии от лишнего и сохраним их себе.

comments = [] for part in scrapped[0].split('назад'):     split_part = part.split('\nОТВЕТИТЬ')[0].split('\n')     if len(split_part) > 1:         comments.append(split_part[1]) comments = comments[3:]  # удалим лишние  comments_woman = comments + scrapped[1:] comments_woman_df = pd.DataFrame({'comment':comments_woman})  comments_woman_df.to_csv('/Users/amakarshina/Desktop/Toxic_comments/Pet-projects/Toxic_comments/data/' + 'comments_woman.csv') comments_woman_df = comments_woman_df[comments_woman_df['comment'].str.len() > 0] comments_woman_df
Пример комментариев из видео про убийц-женщин.

Пример комментариев из видео про убийц-женщин.

Всего под видео о женщинах-убийцах на момент написания этого проекта было 2 358 комментария.

Теперь повторим парсинг для видео про маньяка-мужчину.

driver = webdriver.Chrome(executable_path=binary_path) scrapped_man = [] wait = WebDriverWait(driver, 10) driver.get("https://www.youtube.com/watch?v=_8bXHh3pOvA&t=156s") for item in tqdm(range(200)):     wait.until(EC.visibility_of_element_located((By.TAG_NAME, "body"))).send_keys(Keys.END)     time.sleep(2) for comment in wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, "#content"))):     scrapped_man.append(comment.text) driver.quit() # отчистим от лишнего comments_man = [] for part in scrapped_man[0].split('назад'):     split_part = part.split('\nОТВЕТИТЬ')[0].split('\n')     if len(split_part) > 1:         comments_man.append(split_part[1]) # сохраним comments_man = comments_man + scrapped[1:] comments_man_df = pd.DataFrame({'comment':comments_man})  comments_man_df.to_csv('/Users/amakarshina/Desktop/Toxic_comments/Pet-projects/Toxic_comments/data/' + 'comments_man.csv') comments_man_df = comments_man_df[comments_man_df['comment'].str.len() > 0]
Пример комментариев из видео про убийцу-мужчину.

Пример комментариев из видео про убийцу-мужчину.

Под роликом про Джека-потрошителя на момент написания этого проекта было 2 323 комментария.

Ключевые слова

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

man_counter = CountVectorizer(ngram_range=(1, 1)) woman_counter = CountVectorizer(ngram_range=(1, 1))  # применяем счетчики к текстам man_count = man_counter.fit_transform(comments_man_df['text_clear']) woman_count = woman_counter.fit_transform(comments_woman_df['text_clear'])  # создаем DataFrame с частотами слов man_frequence = pd.DataFrame(     {'word': man_counter.get_feature_names_out(),      'frequency': man_count.toarray().sum(axis=0)} ).sort_values(by='frequency', ascending=False)  woman_frequence = pd.DataFrame(     {'word': woman_counter.get_feature_names_out(),      'frequency': woman_count.toarray().sum(axis=0)} ).sort_values(by='frequency', ascending=False) display(man_frequence.shape[0]) display(woman_frequence.shape[0])  # фильтруем уникальные слова man_frequence_filtered = man_frequence.query('word not in @woman_frequence.word')[:100] woman_frequence_filtered = woman_frequence.query('word not in @man_frequence.word')[:100]  # Создаем облако слов wordcloud_man = WordCloud(     background_color="black",     colormap='Blues',     max_words=200,     width=1600,     height=1600 ).generate_from_frequencies(dict(man_frequence_filtered.values))  # создаем облако слов wordcloud_woman = WordCloud(     background_color="black",     colormap='Oranges',     max_words=200,     width=1600,     height=1600 ).generate_from_frequencies(dict(woman_frequence.values))  # Визуализируем fig, ax = plt.subplots(1, 2, figsize=(20, 12))  ax[0].imshow(wordcloud_man, interpolation='bilinear') ax[1].imshow(wordcloud_woman, interpolation='bilinear')  ax[0].set_title(     f'Топ 100 слов наиболее частотных,\n уникальных слов в комментариях мужчин',     fontsize=20 ) ax[1].set_title(     f'Топ 100 слов наиболее частотных,\n уникальных слов в комментариях женщин',     fontsize=20 )  ax[0].axis("off") ax[1].axis("off")  plt.show()

Оценка модели на наших видео

Перейдем к заключительной оценке: найдем доли негативных комментариев при оптимальном пороговом значении.

woman_share_neg = (comments_woman_df['negative_proba'] >  0.575758).sum() / comments_woman_df.shape[0] woman_share_neg >>> 0.766156462585034  man_share_neg = (comments_man_df['negative_proba'] >  0.575758).sum() / comments_man_df.shape[0] man_share_neg >>> 0.7492447129909365

Выводы

  • Высокая доля негативных комментариев: Оба видео имеют значительную долю негативно окрашенных комментариев, превышающую 70%. Это указывает на то, что под TRUE CRIME роликами бо́льшая часть комментариев действительно негативная.

  • Незначительное различие между полами: Доля негативных комментариев под видео про убийц женщин немного превышает долю негативных комментариев под роликом про маньяков мужчин (0.766 против 0.749). Это указывает на то, что в целом различия в тональности комментариев между этими двумя типами видео незначительны.

Надеюсь, что это небольшое исследование было и нтересно для вас, буду рада если подпишитесь на меня тут или на telegram — канал, в котором пишу про свое развитие в области Data Science и делюсь прогрессом. Всем желаю классных проектов!


ссылка на оригинал статьи https://habr.com/ru/articles/830100/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *