
Привет! Я тут активно пытаюсь охватить разные области в сфере 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:
где f(t,d) — количество вхождений термина t в документ d , а Nd — общее количество терминов в документе d.
IDF измеряет важность термина по отношению ко всему корпусу документов. Чем реже термин встречается в корпусе, тем выше его IDF. Формула для расчета IDF:
где N — общее количество документов в корпусе D, а ∣{d∈D:t∈d}∣ — количество документов, содержащих термин t.
TF-IDF объединяет TF и IDF для оценки важности термина в конкретном документе. Формула для расчета TF-IDF:
Для использования 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 я использовала логистическую регрессию, т.к она хорошо подходит для задачи бинарной классификации.
Если вы еще не знакомы с данной моделью, но уже слышали про линейную регрессию, то можно сказать, что вы почти знаток. Дело в том, что логистическая регрессия по сути это линейная регрессия, к результату которой в конце применяется логистическая функция (например, сигмоида).
Формула сигмоидной функции:
где z— линейная комбинация признаков и их весов: z = β0+β1x1+β2x2+…+β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/
Добавить комментарий