Привет! Я тут активно пытаюсь охватить разные области в сфере Data Science и решила, что было бы классно покопаться c обработкой естественного языка (NLP) на примере комментариев YouTube. Так как после работы я часто смотрю видео Саши Сулим, я задалась вопросом: "Интересно, а есть ли различия в оценке зрителями видео про маньяков в зависимости от пола!? Или нам не важно, кто был убийцей - мужчина/женщина?"
Так я пришла к тому, что могу взять задачку классификации комментариев по оценке их негативности в качестве pet-проекта. То, насколько это получилось, предлагаю оценить вам.
Весь код можно найти в github, а в рамках данной статьи я подробнее опишу процесс исследования данной темы.
Для обучения мною был выбран датасет с Kaggle из комментариев, собранных с сайта 2ch.hk и pikabu.ru. Среднестатистический комментарий имеет длину 175 символов, минимальная длина комментария - 21 символ, максимальная - 7 403.
Для начала посмотрим что из себя представляет наш датасет. Для этого проведем стандартный анализ:
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 - нейтральные.
Любые сырые данные нужно предобаботать. Для этого есть несколько важных этапов: токенизация, удаление пунктуации и стоп-слов, а также стемминг. Давайте приступим!
# возьмем для примера один комментарий
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 измеряет насколько часто термин (слово) встречается в документе. Формула для расчета 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 и делюсь прогрессом. Всем желаю классных проектов!