Привет! Я тут активно пытаюсь охватить разные области в сфере 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 и делюсь прогрессом. Всем желаю классных проектов!

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


  1. ArtymQ
    20.07.2024 05:17
    +2

    Понемногу интересуюсь data science, всегда было интересно увидеть рабочий процесс от постановки задачи до анализа результатов)


  1. Kononelder
    20.07.2024 05:17
    +1

    Предлагаю дополнить исследование решением данной задачи с поиощью bert-like моделей и LLM через prompt engineering и сравнить результаты в новой статье. Думаю, было бы интересно


  1. GeorgeNordic
    20.07.2024 05:17

    На каком языке комментарии, можете пояснить? «Девк, кравченк, янд, бухановск, плат, амурха, косто, питан»?


    1. makarstasia Автор
      20.07.2024 05:17

      Облако слов строилось после применения стемминга. Поэтому слова отображены в сокращении до своих грамматических основ.


  1. VADemon
    20.07.2024 05:17

    yt-dlp качает все комментарии к видео с одной команды в формате JSON, со второй команды (и чьей-то матери для подбора правильного фильтра-команды) jq выдает только нужные элементы.