Содержание

  • 1  Подготовка
  • 2  Обучение
  • 3  Выводы
  • 4  Чек-лист проверки

Проект для «Викишоп»¶

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

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

Постройте модель со значением метрики качества F1 не меньше 0.75.

Инструкция по выполнению проекта

  1. Загрузите и подготовьте данные.
  2. Обучите разные модели.
  3. Сделайте выводы.

Для выполнения проекта применять BERT необязательно, но вы можете попробовать.

Описание данных

Данные находятся в файле toxic_comments.csv. Столбец text в нём содержит текст комментария, а toxic — целевой признак.

Подготовка¶

In [1]:
import re
import nltk
import warnings
import datetime
import pandas as pd

from nltk.corpus import stopwords
from sklearn.metrics import f1_score
from joblib import Parallel, delayed
from sklearn.pipeline import Pipeline
from nltk.stem import WordNetLemmatizer
from tqdm.notebook import tqdm_notebook
from sklearn.exceptions import NotFittedError
from sklearn.ensemble import RandomForestClassifier
from nltk.corpus import stopwords as nltk_stopwords, wordnet
from sklearn.linear_model import LogisticRegression, SGDClassifier
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.model_selection import train_test_split, GridSearchCV, cross_val_score, KFold
In [2]:
# снимаем ограничение на количество столбцов
pd.set_option('display.max_columns', None)

# снимаем ограничение на ширину столбцов
pd.set_option('display.max_colwidth', None)

# игнорируем предупреждения
pd.set_option('chained_assignment', None)  

# выставляем ограничение на показ знаков после запятой
pd.options.display.float_format = '{:,.2f}'.format

# Установка ядра tqdm_notebook для отображения прогресса в цикле
tqdm_notebook.pandas()

nltk.download('stopwords')
nltk.download('averaged_perceptron_tagger')
nltk.download('wordnet')
nltk.download('punkt')

#warnings.filterwarnings('ignore')
[nltk_data] Downloading package stopwords to /home/jovyan/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
[nltk_data] Downloading package averaged_perceptron_tagger to
[nltk_data]     /home/jovyan/nltk_data...
[nltk_data]   Unzipping taggers/averaged_perceptron_tagger.zip.
[nltk_data] Downloading package wordnet to /home/jovyan/nltk_data...
[nltk_data]   Package wordnet is already up-to-date!
[nltk_data] Downloading package punkt to /home/jovyan/nltk_data...
[nltk_data]   Package punkt is already up-to-date!
Out[2]:
True
In [3]:
try:
    data = pd.read_csv('toxic_comments.csv', index_col='Unnamed: 0')
except:
    data = pd.read_csv('/datasets/toxic_comments.csv', index_col='Unnamed: 0')
In [4]:
display(data.head(), data.tail(), data.shape)
text toxic
0 Explanation\nWhy the edits made under my username Hardcore Metallica Fan were reverted? They weren't vandalisms, just closure on some GAs after I voted at New York Dolls FAC. And please don't remove the template from the talk page since I'm retired now.89.205.38.27 0
1 D'aww! He matches this background colour I'm seemingly stuck with. Thanks. (talk) 21:51, January 11, 2016 (UTC) 0
2 Hey man, I'm really not trying to edit war. It's just that this guy is constantly removing relevant information and talking to me through edits instead of my talk page. He seems to care more about the formatting than the actual info. 0
3 "\nMore\nI can't make any real suggestions on improvement - I wondered if the section statistics should be later on, or a subsection of ""types of accidents"" -I think the references may need tidying so that they are all in the exact same format ie date format etc. I can do that later on, if no-one else does first - if you have any preferences for formatting style on references or want to do it yourself please let me know.\n\nThere appears to be a backlog on articles for review so I guess there may be a delay until a reviewer turns up. It's listed in the relevant form eg Wikipedia:Good_article_nominations#Transport " 0
4 You, sir, are my hero. Any chance you remember what page that's on? 0
text toxic
159446 ":::::And for the second time of asking, when your view completely contradicts the coverage in reliable sources, why should anyone care what you feel? You can't even give a consistent argument - is the opening only supposed to mention significant aspects, or the ""most significant"" ones? \n\n" 0
159447 You should be ashamed of yourself \n\nThat is a horrible thing you put on my talk page. 128.61.19.93 0
159448 Spitzer \n\nUmm, theres no actual article for prostitution ring. - Crunch Captain. 0
159449 And it looks like it was actually you who put on the speedy to have the first version deleted now that I look at it. 0
159450 "\nAnd ... I really don't think you understand. I came here and my idea was bad right away. What kind of community goes ""you have bad ideas"" go away, instead of helping rewrite them. " 0
(159292, 2)
In [5]:
data.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 159292 entries, 0 to 159450
Data columns (total 2 columns):
 #   Column  Non-Null Count   Dtype 
---  ------  --------------   ----- 
 0   text    159292 non-null  object
 1   toxic   159292 non-null  int64 
dtypes: int64(1), object(1)
memory usage: 3.6+ MB
In [6]:
data.describe()
Out[6]:
toxic
count 159,292.00
mean 0.10
std 0.30
min 0.00
25% 0.00
50% 0.00
75% 0.00
max 1.00
In [7]:
data.isna().sum()
Out[7]:
text     0
toxic    0
dtype: int64
In [8]:
data.duplicated().sum()
Out[8]:
0
In [9]:
data['toxic'].value_counts()
Out[9]:
0    143106
1     16186
Name: toxic, dtype: int64

Комментарий:

Здесь мы наблюдаем значительный дисбаланс классов в целевой переменной. Это в свою очередь может привести к несбалансированности и к переобучению.

Обучение¶

In [10]:
# Инициализация объекта класса TextProcessor
# с параметром языка стоп-слов по умолчанию 'english'
class TextProcessor:
    """
    Класс для обработки текста, включая очистку от лишних символов, удаление стоп-слов и лемматизацию.

    Атрибуты:
        stopwords (set): Множество стоп-слов для указанного языка.
        lemmatizer (WordNetLemmatizer): Объект для лемматизации слов с использованием WordNetLemmatizer.

    Методы:
        clear_text(text): Очищает текст от лишних символов и стоп-слов.
        lemm_text(text): Лемматизирует текст.
        postag_lemm_text(text): Лемматизирует текст с учетом частей речи.
        get_wordnet_pos(word): Возвращает POS-тег WordNet для слова.

    """
    def __init__(self, stopwords_language='english'):
        # Загрузка стоп-слов для указанного языка
        self.stopwords = set(nltk_stopwords.words(stopwords_language))
        # Инициализация объекта для лемматизации слов с помощью WordNetLemmatizer
        self.lemmatizer = WordNetLemmatizer()
        
    # Метод для очистки текста от лишних символов и стоп-слов
    def clear_text(self, text):
        # Приведение текста к нижнему регистру
        text = text.lower()
        # Оставление только латинских символов
        word_list = re.sub(r"[^a-z ]", ' ', text).split()
        # Разделение текста на отдельные слова и удаление нелатинских символов
        # Фильтрация списка слов и удаление стоп-слов
        word_notstop_list = [w for w in word_list if w not in self.stopwords]
        # Сборка очищенных слов в текстовую строку и возврат результата
        return ' '.join(word_notstop_list)
    
    # Метод для лемматизации текста
    def lemm_text(self, text):
        # Разделение текста на отдельные слова
        word_list = text.split()
        # Лемматизация каждого слова в тексте с использованием WordNetLemmatizer
        lemmatized_text = ' '.join([self.lemmatizer.lemmatize(w) for w in word_list])
        # Сборка лемматизированных слов в текстовую строку и возврат результата
        return lemmatized_text
    
    # Метод для лемматизации текста с учетом частей речи
    def postag_lemm_text(self, text):
        # Разделение текста на отдельные слова
        word_list = text.split()
        # Лемматизация каждого слова в тексте с использованием WordNetLemmatizer и определением части речи
        lemmatized_text = ' '.join([self.lemmatizer.lemmatize(w, self.get_wordnet_pos(w)) for w in word_list])
        # Сборка лемматизированных слов в текстовую строку и возврат результата
        return lemmatized_text
    
    @staticmethod
    # Статический метод для определения части речи слова с использованием pos_tag
    def get_wordnet_pos(word):
        # Получение POS-тега для слова с использованием pos_tag
        tag = nltk.pos_tag([word])[0][1][0].upper()
        # Отображение POS-тегов WordNet на первую букву, используемую lemmatize
        tag_dict = {"J": 'a', "N": 'n', "V": 'v', "R": 'r'}
        # Возврат соответствующего POS-тега WordNet или 'n' (существительное) по умолчанию
        return tag_dict.get(tag, 'n')

# Инициализация объекта для обработки текста
text_processor = TextProcessor()
In [11]:
# Очистка текста
data['clean_text'] = data['text'].progress_apply(text_processor.clear_text)

# Лемматизация текста
data['wnl_text'] = data['clean_text'].progress_apply(text_processor.lemm_text)

# Лемматизация с POS-тегами
data['wnlpostag_text'] = data['clean_text'].progress_apply(text_processor.postag_lemm_text)

# Классификаторы и настройки
classifiers = {
    'LogisticRegression': {
        'model': LogisticRegression(solver='liblinear', class_weight='balanced', random_state=12345),
        'data': 'wnl_text'
    },
    'RandomForestClassifier': {
        'model': RandomForestClassifier(n_estimators=10, class_weight='balanced', random_state=12345),
        'data': 'wnl_text'
    },
    'LogisticRegression_POS': {
        'model': LogisticRegression(solver='liblinear', class_weight='balanced', random_state=12345),
        'data': 'wnlpostag_text'
    },
    'RandomForestClassifier_POS': {
        'model': RandomForestClassifier(n_estimators=10, class_weight='balanced', random_state=12345),
        'data': 'wnlpostag_text'
    }
}

# Создание DataFrame для результатов
results = pd.DataFrame(columns=['Model', 'Data', 'F1-Score', 'Time'])
  0%|          | 0/159292 [00:00<?, ?it/s]
  0%|          | 0/159292 [00:00<?, ?it/s]
  0%|          | 0/159292 [00:00<?, ?it/s]
In [12]:
# Создание DataFrame для результатов
results = pd.DataFrame(columns=['Model', 'Data', 'F1-Score', 'Time'])
In [13]:
# Кросс-валидация с использованием моделей
kfold = KFold(n_splits=5, random_state=12345, shuffle=True)

# Создайте функцию для обучения и оценки модели
def train_model(model, tf_idf_train, target_train):
    # Обучение модели
    model.fit(tf_idf_train, target_train)
    # Расчет F1-оценки
    f1_scores = cross_val_score(model, tf_idf_train, target_train, cv=kfold, scoring='f1')
    return f1_scores.mean()

for model_name, model_params in tqdm_notebook(classifiers.items(), desc='Models', leave=False):
    model = model_params['model']
    model_data = model_params['data']
    
    # Разделение на тренировочный и тестовый наборы
    features_train, features_test, target_train, target_test = train_test_split(
        data[model_data], data['toxic'].values, test_size=0.2, stratify=data['toxic'].values, 
        shuffle=True, random_state=12345
    )
    
    # Преобразование текста в TF-IDF векторы
    count_tf_idf = TfidfVectorizer()
    tf_idf_train = count_tf_idf.fit_transform(features_train)
    tf_idf_test = count_tf_idf.transform(features_test)
    
    # Обучение модели и расчет F1-оценки с использованием параллельной обработки
    beg_time = datetime.datetime.now()
    f1_scores = Parallel(n_jobs=-1)(
        delayed(train_model)(model, tf_idf_train, target_train) for _ in range(5)
    )
    time_taken = (datetime.datetime.now() - beg_time).seconds
    
    # Предсказание на тестовом наборе данных
    predictions = model.predict(tf_idf_test)
    
    # Расчет F1-оценки
    f1_score_value = f1_score(target_test, predictions)
    
    # Запись результатов в DataFrame
    results = results.append({
        'Model': model_name,
        'Data': model_data,
        'F1-Score': f1_score_value,
        'Time': time_taken
    }, ignore_index=True)
Models:   0%|          | 0/4 [00:00<?, ?it/s]

Выводы¶

In [15]:
results
Out[15]:
Model Data F1-Score Time
0 LogisticRegression wnl_text 0.76 349
1 RandomForestClassifier wnl_text 0.60 2488
2 LogisticRegression_POS wnlpostag_text 0.76 377
3 RandomForestClassifier_POS wnlpostag_text 0.59 2256
In [22]:
# Выбор лучшей модели (логистической регрессии) из результатов
best_model_name = results.loc[results['F1-Score'].idxmax(), 'Model']
best_model_data = classifiers[best_model_name]['data']
best_model = classifiers[best_model_name]['model']

# Разделение на тренировочный и тестовый наборы для лучшей модели
features_train_best, features_test_best, target_train_best, target_test_best = train_test_split(
    data[best_model_data], data['toxic'].values, test_size=0.2, stratify=data['toxic'].values,
    shuffle=True, random_state=12345
)

# Преобразование текста в TF-IDF векторы для лучшей модели
count_tf_idf_best = TfidfVectorizer()
tf_idf_train_best = count_tf_idf_best.fit_transform(features_train_best)
tf_idf_test_best = count_tf_idf_best.transform(features_test_best)

# Обучение лучшей модели
best_model.fit(tf_idf_train_best, target_train_best)

# Предсказание на тестовом наборе данных
predictions_best = best_model.predict(tf_idf_test_best)

# Вычисление метрики F1-Score для лучшей модели
f1_score_best = f1_score(target_test_best, predictions_best)

# Вывод результата
print('F1-оценка для лучшей модели (%s): %.3f' % (best_model_name, f1_score_best))
F1-оценка для лучшей модели (LogisticRegression): 0.760

Не было обнаружено пропусков или дубликатов в данных, и все требуемые модели были успешно обучены.
Модель Logistic Regression показала лучший результат с точки зрения метрики F1 исследования на тестовой выборке, ее оценка составляет 0.76.

Чек-лист проверки¶

  • Jupyter Notebook открыт
  • Весь код выполняется без ошибок
  • Ячейки с кодом расположены в порядке исполнения
  • Данные загружены и подготовлены
  • Модели обучены
  • Значение метрики F1 не меньше 0.75
  • Выводы написаны
Туториалы по Pipeline:⚠️:
  • https://towardsdatascience.com/nlp-with-pipeline-gridsearch-5922266e82f4
  • https://scikit-learn.org/1.0/auto_examples/model_selection/grid_search_text_feature_extraction.html
  • https://habr.com/ru/articles/266025/
  • https://medium.datadriveninvestor.com/improve-the-text-classification-results-with-a-suitable-preprocessing-step-gridsearchcv-and-f19cb3e182a3
Полезные материалы:
  • Про BERT:
    • Яндекс Практикум, RuBERT
    • https://towardsdatascience.com/fine-tuning-bert-for-text-classification-54e7df642894 - Полный туториал-обучение по BERT
    • https://medium.com/analytics-vidhya/text-classification-with-bert-using-transformers-for-long-text-inputs-f54833994dfd - Тоже туториал про классификации, но для длинных текстов
    • https://huggingface.co/docs/transformers/tasks/sequence_classification - Официальный пример из документации huggingface
    • https://colab.research.google.com/github/huggingface/notebooks/blob/main/examples/text_classification.ipynb - Отличный туториал по реальному соревнованию
  • Про GPU:
    • https://colab.research.google.com/ - Google Colab для эффективного обучения
    • https://www.tutorialspoint.com/google_colab/google_colab_using_free_gpu.htm - Как включить GPU в Google Colab
    • https://huggingface.co/docs/transformers/performance - Как BERT обучать на GPU
  • Про catboost:
    • https://towardsdatascience.com/10x-times-fast-catboost-training-speed-with-an-nvidia-gpu-5ffefd9b57a6 - Сравнение GPU и CPU + код обучения на GPU
    • https://colab.research.google.com/github/catboost/tutorials/blob/master/tools/google_colaboratory_cpu_vs_gpu_tutorial.ipynb - Тетрадка туториал по GPU и CPU catboost
  • Про Pipelines:
    • https://habr.com/ru/post/266025/
    • https://towardsdatascience.com/nlp-with-pipeline-gridsearch-5922266e82f4
    • https://scikit-learn.org/0.24/auto_examples/model_selection/grid_search_text_feature_extraction.html
    • https://medium.com/analytics-vidhya/ml-pipelines-using-scikit-learn-and-gridsearchcv-fe605a7f9e05

Сейчас активно используются RNN (LSTM) и трансформеры (BERT, ELMO, GPT/2/3/n и др.). Они не являются панацеей, так как и TF-IDF или Word2Vec + модели из классического ML тоже могут решать задачи в текстах.
BERT тяжелый, есть готовые модели, есть надстройки над библиотекой transformers. Если, обучать BERT на GPU (можно в Google Colab или Kaggle), то должно быть побыстрее.Также не всегда есть возможность обучиться на всём датасете из-за нехватки памяти, поэтому стоит брать выборки из датасета
https://huggingface.co/transformers/model_doc/bert.html
https://t.me/renat_alimbekov
https://colah.github.io/posts/2015-08-Understanding-LSTMs/ - Про LSTM
https://web.stanford.edu/~jurafsky/slp3/10.pdf - про энкодер-декодер модели, этеншены
https://pytorch.org/tutorials/beginner/transformer_tutorial.html - официальный гайд по трансформеру от создателей pytorch
https://transformer.huggingface.co/ - поболтать с трансформером
Библиотеки: allennlp, fairseq, transformers, tensorflow-text — множество реализованных методов для трансформеров методов NLP
Word2Vec https://radimrehurek.com/gensim/models/word2vec.html

Пример BERT с GPU:

%%time
from tqdm import notebook
batch_size = 2 # для примера возьмем такой батч, где будет всего две строки датасета
embeddings = [] 
for i in notebook.tqdm(range(input_ids.shape[0] // batch_size)):
        batch = torch.LongTensor(input_ids[batch_size*i:batch_size*(i+1)]).cuda() # закидываем тензор на GPU
        attention_mask_batch = torch.LongTensor(attention_mask[batch_size*i:batch_size*(i+1)]).cuda()

        with torch.no_grad():
            model.cuda()
            batch_embeddings = model(batch, attention_mask=attention_mask_batch)

        embeddings.append(batch_embeddings[0][:,0,:].cpu().numpy()) # перевод обратно на проц, чтобы в нумпай кинуть
        del batch
        del attention_mask_batch
        del batch_embeddings

features = np.concatenate(embeddings)

Можно сделать предварительную проверку на наличие GPU.
Например, так: device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")
Тогда вместо .cuda() нужно писать .to(device)