<jupyter_start><jupyter_text>*Автор: <NAME>**Дата: 17.04.2018*   Обнаружение и классификация недопустимых комментариев на Википедии  Часть 1. Описание набора данных и признаков Нередко обсуждение какой-либо темы на популярных сайтах скатывается во взаимные оскорбления и угрозы, что является недопустимым с точки зрения норм этики, морали и человеческого общения, а также деструктивно влияет на саму обсуждаемую тему. Люди просто перестают общаться, видя такое в комментариях. Данная проблема очень актуальна для Википедии. Каждая статья и её правки активно обсуждаются, и это общение бывает иногда не совсем корректным со стороны некоторых пользователей. Необходимо выявлять такие комментарии и удалять их, чтобы обсуждения были в рамках норм человеческого общения, не было переходов на личности и взаимных оскорблений.  Источник данных В проекте используются данные из соревнования на платформе Kaggle "Toxic Comment Classification Challenge". Компания "Jigsaw" (подразделение Google), занимающаяся исследованиями в области онлайн-общения пользователей, разместила данные, содержащие корпус размеченных комментариев из Википедии.  Цели и задачи Необходимо определить с какой вероятностью каждый комментарий может быть отнесён к одному из следующих классов (целевая переменная):1 - язвительный2 - издевательский3 - непристойная лексика4 - угроза5 - оскорбление6 - личная неприязньВ отличие от обычной постановки задачи классификации (multiclass), в данном случае один комментарий может с той или иной вероятностью принадлежать одновременно к нескольким классам (multilabel). Ценность такой задачи заключается в том, что в дальнейшем решение можно адаптировать под любую интернет-платформу и успешно использовать для пресечения оскорблений и перехода на личности.  Описание набора данных Представленные данные содержат два признака:'id' - id комментария'comment_text' - текст комментарияЦелевая переменная представлена шестью векторами-столбцами. Каждый из них содержит значения 0 или 1 в зависимости от того, принадлежит ли комментарий к данному классу или нет:'toxic' - язвительный комментарий'severe_toxic' - издевательский комментарий'obscene' - комментарий содержит непристойную лексику'threat' - комментарий содержит угрозы'insult' - оскорбительный комментарий'identity_hate' - комментарий содержит признаки личной неприязниСледует заметить, что разметка данных осуществлялась разными людьми с использованием краудсорсинга, поэтому в той или иной степени классы целевой переменной имеют частично субъективный характер.  Часть 2. Первичный анализ данных  Импорт библиотек<jupyter_code># базовые библиотеки
import pandas as pd 
import numpy as np

# sklearn библиотеки
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer, ENGLISH_STOP_WORDS
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.preprocessing import StandardScaler

# NLP библиотеки
import string
import re    

# библиотеки визуализации данных
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline

#scipy
from scipy.sparse import csr_matrix, hstack
from scipy import sparse

#вспомогательные библиотеки
from PIL import Image
import pprint
import time
import warnings
warnings.filterwarnings('ignore')

color = sns.color_palette("Set2", 10)
sns.set_style("whitegrid")
sns.set_context("paper")
sns.palplot(color)<jupyter_output><empty_output><jupyter_text>Первичный анализ данных Посмотрим на десять случайных объектов обучающего и тестового датасетов и выведем общее количество объектов в них. Также посмотрим на число уникальных значений в признаке id комментария.<jupyter_code>train = pd.read_csv('data/train.csv')
test = pd.read_csv('data/test.csv')
print('Обучающая выборка содержит {} объект'.format(len(train)))
print('Число уникальных значений в признаке id: {}\n'.format(train.id.nunique()))
train.sample(10)
print('Тестовая выборка содержит {} объекта'.format(len(test)))
print('Число уникальных значений в признаке id: {}'.format(test.id.nunique()))
test.sample(10)<jupyter_output><empty_output><jupyter_text>Как описывалось выше, тренировочные данные содержат два объясняющих признака и шесть вектор-столбцов целевой переменной. Аналогичная ситуация в тестовой выборке за исключением того, что в ней удалена целевая переменная. Всего - 159 571 комментарий в обучающей выборке и 153 164 комментариев в тестовой. Число уникальных значений в признаке id комментария в каждой выборке соответствует количеству объектов. Это хэш id комментария и он бесполезен в нашем случае. Удалим его. Таким образом, остается один единственный объясняющий признак - текст комментария. Следует обратить внимание ещё на два момента:1. Как минимум в тестовой выборке присутствуют неанглоязычные комментарии. 2. Текстовые данные сильно 'замусорены'Теперь посмотрим, есть ли пропущенные значения в данных.<jupyter_code>train.drop('id', axis=1, inplace=True)
null_count = train.isnull().sum(axis=0).reset_index()
null_count.columns = ['col_name', 'null_count']
null_count
test.drop('id', axis=1, inplace=True)
null_count = test.isnull().sum(axis=0).reset_index()
null_count.columns = ['col_name', 'null_count']
null_count<jupyter_output><empty_output><jupyter_text>Пропусков нет и это хорошая новость. Тренировочная и тестовая выборки имеют примерно одинаковое количество объектов.Посмотрим, как распределены классы целевой переменной в обучающей выборке. Следует помнить, что один объект может принадлежать к нескольким классам.<jupyter_code>plt.figure(figsize=(12,8))
ax1 = sns.barplot(train.describe().loc['mean'].index, train.describe().loc['mean'])
plt.ylabel('Доля объектов от общего числа', fontsize=12)
plt.xlabel('Класс', fontsize=12)
plt.title('Распределение классов в обучающей выборке', fontsize=15)
plt.xticks(rotation=45, fontsize=11)
plt.grid(False)

for p in ax1.patches:
    x=p.get_bbox().get_points()[:,0]
    y=p.get_bbox().get_points()[1,1]
    ax1.annotate('{0:.2f}%'.format(p.get_height()*100), (x.mean(), y), ha='center', va='bottom');<jupyter_output><empty_output><jupyter_text>Часть 3. Первичный визуальный анализ данных Для дальнейшего визуального анализа добавим следующие признаки в обучающую выборку:'clean' - Нормальный комментарий (не принадлежит ни к одному из классов)'class_sum' - Число классов, к которым отнесён комментарий<jupyter_code>y_cols = ['toxic', 'severe_toxic', 'obscene', 'threat', 'insult', 'identity_hate']
class_sum = train.loc[:,y_cols].sum(axis=1)
train['class_sum'] = class_sum
train['clean'] = (class_sum==0).astype(int)
train.head()<jupyter_output><empty_output><jupyter_text>Баланс классов Далее посмотрим на число классов в обучающей выборке.<jupyter_code>class_counts = train.loc[:, y_cols+['clean']].sum()
plt.figure(figsize=(10,6))
ax1 = sns.barplot(class_counts.index, class_counts, color=color[2], alpha=0.8)
plt.ylabel('Количество объектов', fontsize=12)
plt.xlabel('Класс', fontsize=12)
plt.title('Распределение классов в обучающей выборке', fontsize=15)
plt.xticks(fontsize=11)
plt.grid(False)

for p in ax1.patches:
    x=p.get_bbox().get_points()[:,0]
    y=p.get_bbox().get_points()[1,1]
    ax1.annotate('{:.0f}'.format(p.get_height()), (x.mean(), y), ha='center', va='bottom')<jupyter_output><empty_output><jupyter_text>Видно, что в данных присутствует сильный дисбаланс классов. И это нормальная жизненная ситуация, подавляющее большинство людей предпочитают уважительное и конструктивное общение. Тем более на таком сайте как Википедия. Обратим внимание на то, что общая сумма объектов больше 153 164 и это тоже нормально, так как один и тот же комментарий может относится к нескольким классам.    Количество классов у одного комментария<jupyter_code>plt.figure(figsize=(10,6))
ax1 = sns.barplot(train.class_sum.value_counts().index, train.class_sum.value_counts(), color=color[1], alpha=0.8)
plt.ylabel('Количество комментариев', fontsize=12)
plt.xlabel('Количество классов', fontsize=12)
plt.title('Количество классов, приходящихся на комментарий', fontsize=15)
plt.xticks(fontsize=11)
plt.grid(False)

for p in ax1.patches:
    x=p.get_bbox().get_points()[:,0]
    y=p.get_bbox().get_points()[1,1]
    ax1.annotate('{:.0f}'.format(p.get_height()), (x.mean(), y), ha='center', va='bottom')<jupyter_output><empty_output><jupyter_text>Нормальных комментариев, не относящихся ни к одному из классов ровно столько же, сколько и на предыдущем графике. Это очевидно. Комментариев, одновременно содержащих в себе язвительность, издевку, обсценную лексику, оскорбления, угрозы и личную неприязнь, то есть комментарий имеет признаки каждого класса, ничтожно мало - примерно 0.02%. Больше всего комментариев относится только к какому-то одному определенному классу. В то же время довольно большое число объектов относится к двум, трем и четырем классам одновременно. Последняя особенность довольно интересна, наверняка это близкие по "духу" классы. Проверим это предположение визуально, но сначала давайте посмотрим на пример комментария, содержащего признаки всех шести классов.<jupyter_code>train[train.class_sum==6].reset_index(drop=True).loc[0,'comment_text']<jupyter_output><empty_output><jupyter_text>Да, это действительно эталон недопустимого комментария. Кстати, на этом примере хороша видна одна особенность ругани в интернете - люди переходят на капслок. Возможно, это можно будет использовать в дальнейшем.  Совместные появления классов в комментариях<jupyter_code>heat = np.zeros((6,6))
for idx,col in enumerate(y_cols):
    if col=='severe_toxic':
        heat[idx,1:]=train[train[col]==1].groupby('class_sum')['class_sum'].count().values
    else:
        heat[idx,:]=train[train[col]==1].groupby('class_sum')['class_sum'].count().values
plt.figure(figsize=(12,8))
plt.title('Корреляция класса и числа классов в комментарии', fontsize=20)
ax = sns.heatmap(heat, cmap='viridis', fmt='.0f', xticklabels=[1,2,3,4,5,6], yticklabels=y_cols, annot=True)
ax.set(xlabel='Число классов в комментарии', ylabel='Класс')
ax.tick_params(labelsize=10)
plt.yticks(rotation=0, fontsize=11)
plt.xticks(fontsize=11);<jupyter_output><empty_output><jupyter_text>Довольно интересные результаты. Во-первых, нет ни одного комментария, содержащего только один класс "severe_toxic". Во-вторых, подавляющее большинство комментариев с одним классом отнесены к "toxic". В-третьих, комментарии с признаками трех классов чаще всего относятся к: "toxic", "insult", "obscene". Это объяснимо, язвительность, мат и оскорбления всегда идут рука об руку. И еще одно наблюдение, которое непросто объяснить - классы "severe_toxic" и "identity_hate" встречаются чаще всего в комментариях с признаками четырех классов. Посмотрим теперь на это под другим углом - как часто определенный класс появляется с другими классами.<jupyter_code>fig, axes = plt.subplots(nrows=3, ncols=2, figsize=(16, 12))
fig.suptitle('Совместные появления классов в комментариях',fontsize = 18)

for idx, col in  enumerate(y_cols): 
    temp_list = y_cols[:]
    temp_list.remove(col)
    sub_series = train[train[col]==1].loc[:,temp_list].sum()
    ax1 = sns.barplot(sub_series.index, sub_series, color=color[idx], alpha=0.8, ax=axes[idx // 2][idx % 2])
    ax1.tick_params(labelsize=10)
    ax1.set_title(col+' ({} комментариев)'.format(train.loc[:,col].sum()), fontsize= 12)
    ax1.set_ylabel('Число совместных появлений')
    for p in ax1.patches:
        x=p.get_bbox().get_points()[:,0]
        y=p.get_bbox().get_points()[1,1]
        ax1.annotate('{:.0f}'.format(p.get_height()), (x.mean(), y), ha='center', va='bottom')
    
    ax1.grid(False)<jupyter_output><empty_output><jupyter_text>Ситуация проясняется, комментарии с признаками класса "severe_toxic" абсолютно всегда размечаются и как "toxic". Более того, комментарии с признаками других классов тоже в подавляющем большинстве случаев размечаются и как "toxic".  Длина комментария Влияет ли как-то длина комментария (в символах) на нормальность или недопустимость комментария?<jupyter_code>train['length'] = train['comment_text'].apply(len)
test['length'] = test['comment_text'].apply(len)<jupyter_output><empty_output><jupyter_text>Посмотрим на комментарии с числом символов, к примеру, восемь:<jupyter_code>train[train.length==8]<jupyter_output><empty_output><jupyter_text>Два комментария из трех определенно неприемлимы для нормального общения. Рассмотрим этот признак визуально более глобально. Для начала взглянем на распределение признака:<jupyter_code>plt.figure(figsize=(15,6))
plt.suptitle("Распределение признака 'Длина комментария' в обучающей выборке",fontsize=20)

plt.subplot2grid((2,1),(0,0))
plt.xlim(0, train.length.max())
sns.distplot(train.length.values, hist=False, color=color[0], kde_kws={"shade": True, "lw": 2})
plt.title("Количество символов")

plt.subplot2grid((2,1),(1,0))
plt.xlim(0, train.length.max())
sns.boxplot(train.length.values);<jupyter_output><empty_output><jupyter_text>Максимальная длина комментария в выборке - 5000 символов. Причем основная масса комментариев имеет длину менее 1000 символов. Посмотрим на распределение этого же признака в тестовой выборке.<jupyter_code>plt.figure(figsize=(15,6))
plt.suptitle("Распределение признака 'Длина комментария' в тестовой выборке",fontsize=20)

plt.subplot2grid((2,1),(0,0))
plt.xlim(0, test.length.max())
sns.distplot(test.length.values, hist=False, color=color[0], kde_kws={"shade": True, "lw": 2})
plt.title("Количество символов")

plt.subplot2grid((2,1),(1,0))
plt.xlim(0, test.length.max())
sns.boxplot(test.length.values);<jupyter_output><empty_output><jupyter_text>Видим совершенно аналогичную ситуацию. Максимальная длина в 5000 символов говорит нам о том, что скорее всего была проведена дополнительная предобработка данных.Сравним длину комментариев в нормальных комментариях и неприемлимых. Рассмотрим только комментарии с длиной менее 1000 символов.<jupyter_code>plt.figure(figsize=(12,8))
sns.violinplot(y=train['length'].loc[train['length']<1000],x=train.clean.map({0:'Нет', 1:'Да'}), data=train,split=True,inner="quart")
plt.xlabel('Нормальный комментарий?', fontsize=14)
plt.ylabel('Число символов в комментарии', fontsize=14)
plt.xticks(fontsize=11)
plt.title("Распределение числа символов в зависимости от приемлимости комментария", fontsize=15);<jupyter_output><empty_output><jupyter_text>На первый взгляд, неприемлимые комментарии более короткие и имеют медианное значение около 100 символов, в то время как нормальные комментарии имеют медианное значение почти 200 символов. Вообще, резонно предположить, что недопустимые комментарии не будут очень длинными. Писать целый трактат, чтобы кого-то оскорбить это по меньшей мере странно.   Топ 10 слов в классах комментариев Пришло время посмотреть, какие слова наиболее часто употребляют авторы недопустимых комментариев.<jupyter_code>cv_train = CountVectorizer(ngram_range=(1, 1), max_features=1000, min_df=5, stop_words=stopwords.words('english'))
X_train = cv_train.fit_transform(train.comment_text.values).toarray()

top10_words_list=[]
top10_freq_list=[]
for col in y_cols:
    top10_words = []
    word_freq = X_train[train[col]==1].sum(axis=0)
    top10_list = word_freq.argsort()[-10:][::-1]
    for i in range(10):
        top10_words.append(list(cv_train.vocabulary_.keys())[list(cv_train.vocabulary_.values()).index(top10_list[i])])
    top10_words_list.append(top10_words)
    top10_freq_list.append(sorted(word_freq, reverse=True)[:10])
fig, axes = plt.subplots(nrows=3, ncols=2, figsize=(16, 12))
fig.suptitle('Топ-10 слов в каждом классе',fontsize = 18)

for i, col in enumerate(y_cols): 
    temp_list = y_cols[:]
    temp_list.remove(col)
    ax1 = sns.barplot(top10_words_list[i], top10_freq_list[i], color=color[i+2], alpha=0.8, ax=axes[i // 2][i % 2])
    ax1.tick_params(labelsize=10)
    ax1.set_title(col+' ({} комментариев)'.format(train.loc[:,col].sum()), fontsize= 12)
    ax1.set_ylabel('Количество употреблений в комментариях')
    for p in ax1.patches:
        x=p.get_bbox().get_points()[:,0]
        y=p.get_bbox().get_points()[1,1]
        ax1.annotate('{:.0f}'.format(p.get_height()), (x.mean(), y), ha='center', va='bottom')
    
    ax1.grid(False)<jupyter_output><empty_output><jupyter_text>Слово "fuck" лидирует во всех классах кроме комментариев, содержащих признаки угроз и личной неприязни. Харктерная особенность для этого слова в том, что в классе "severe_toxic" оно употребляется наиболее часто, в среднем 4 с лищним раза на один комментарий. Это много. В то же время слово "die" употребляется в среднем почти три раза в одном комментарии. В комментариях с признаками личной неприязни также есть свое слово-лидер - более двух раз на один комментарий. В то же время видно, что в топе есть и довольно нейтральные слова - wikipedia, like, go. Возможно, в дальнейшем их можно будет добавить в список стоп-слов.  Часть 4. Инсайты, найденные зависимости Подведем итог анализа данных:1. В данных обнаружились неанглоязычные комментарии. Это усложняет задачу, но можно будет использовать как дополнительный признак.2. Отсутствуют пропуски в данных3. Максимальная длина комментария в обучающей и тестовой выборках - 5000 символов. В то время как подавляющее большинство комментариев имеет не более 1000 символов.4. В данных сильный дисбаланс классов, это немного усложнит задачу, но в целом ничего страшного.5. Оскорбительные комментарии в среднем относятся одновременно к одному, двум, трем и реже четырем классам. Довольно мало комментариев, содержащих в себе признаки пяти и тем более шести классов.6. В процессе анализа данных обнаружился весьма хороший инсайт - нет ни одного комментария, относящегося только к одному классу "severe_toxic"7. Наибольшее количество комментариев с одним классом отнесены к классу "toxic"8. Комментарии имеющие признаки трех классов чаще всего относятся к: "toxic", "insult" и "obscene"9. Классы "severe_toxic" и "identity_hate" встречаются чаще всего в комментариях с признаками четырех классов10. Комментарии с признаками всех классов кроме "toxic" в подавляющем большинстве случаев размечаются и как "toxic". 11. Медианное значение длины недопустимых комментариев меньше этого же значения нормальных комментариев примерно на 100 символов.12. Некоторые классы имеют свои характерные особенности - наиболее часто употребляемые слова. Это может быть хорошим признаком в дальнейшем.  Часть 5. Выбор метрики В задачах бинарной классификации наиболее часто используются следующие метрики качества в зависимости от решаемой задачи:- Доля правильных ответов (Accuracy)- Полнота (Recall)- Точность (Precision). - F-мера (F1-score). Среднее гармоническое точности и полноты.- Функция логистических потерь (LogLoss)- Площадь под ROC кривой (ROC-AUC)- Коэффициент Джини (Normalized Gini)Первые три метрики самые простые и оттого легко интерпретируемы, но имеют много минусов. И один из них - они совершенно не подходят для задач с сильным дисбалансом классов. Для нашей задачи наиболее подходят три последние метрики. Logloss сильно штрафует классификатор за уверенность в неверном ответе, так как максимизирует долю правильных ответов путем штрафа за неверные предсказания. Коэффициент Джини используется в основном в банковском секторе и страховании, это обусловлено тем, что в этой сфере в данных обычно сильнейший дисбаланс классов. Наиболее подходящая метрика для задачи - ROC-AUC. Она является отличным способом оценить предсказания вероятностей модели в целом, не привязываясь к конкретному порогу. Но мой индивидуальный проект подразумевает многоклассовую классификацию. В данном случае обычно используются два подхода:- микроусреднение- макроусреднениеВ первом случае на расчет метрики качества влияет вклад каждого класса в зависимости от его размера. Во втором случае рассчитывается метрика качества для каждого класса и потом усредняется, таким образом значение метрики не зависит от мощности класса. Второй класс наиболее подходит для решаемой задачи. Подводя итог, в задаче будет использоваться метрика качетва ROC-AUC с макроусреднением по классам. Именно таким образом рассчитывался итоговый результат в соревновании на Kaggle.  Часть 6. Выбор модели Так как в задаче используются текстовые данные, то очевидно, что матрица признаков будет сильно разреженной и иметь большую размерность. Количество признаков будет исчисляться десятками тысяч. Алгоритмы на основе деревьев решений очень долго и не всегда хорошо обучаются на таких данных. В то же время линейные модели прекрасно обучаются на таких данных и показывают отличные результаты. Классическими моделями в подобных задачах классификации являются:- LogisticRegression- SGDClassifier- Vowpal WabbitДополнительным плюсом этих моделей является то, что они отлично интерпретируемы и имеют гиперпараметры, которые можно подбирать на кросс-валидации. Последние две модели используются преимущественно на гигабайтах и терабайтах данных, что объясняется очень просто, обе они используют стохастический градиентный спуск при оптимизации. Однако на практике эти модели показывают обычно более худшие результаты, чем логистическая регрессия. Хотя теоретически можно подобрать гиперпараметры этих моделей, чтобы они не уступали в качестве логистической регрессии. Данные индивидуального проекта относительно небольшие и использование логистической регрессии представляется наилучшим выбором в этом случае.  Часть 7. Предобработка данных<jupyter_code>train = pd.read_csv('data/train.csv')
test = pd.read_csv('data/test.csv')
y_train = train[['toxic', 'severe_toxic', 'obscene', 'threat', 'insult', 'identity_hate']].values
train.drop(['id', 'toxic', 'severe_toxic', 'obscene', 'threat', 'insult', 'identity_hate'], axis=1, inplace=True)
test.drop('id', axis=1, inplace=True)
y_cols = ['toxic', 'severe_toxic', 'obscene', 'threat', 'insult', 'identity_hate']<jupyter_output><empty_output><jupyter_text>Итак, изначально в данных присутстует только один признак - текст комментария.Для преобразования текста будем использовать TfidfVectorizer из библиотеки Sklearn. TfidfVectorizer преобразует текст в матрицу TF-IDF. TF-IDF позволит оценить важность термина для какого-либо комментария относительно всех остальных комментариев. Принцип такой — если слово встречается в каком-либо комментарии часто, при этом встречаясь редко в остальных комментариях — это слово имеет большую значимость для этого комментария и соответственно классам, к которым этот комментарий относится. Слова, встречающиеся во многих комментариях, например, предлоги или междометия получат очень низкий вес TF-IDF (потому что часто встречаются во всех комментариях), а важные — высокий.Теперь кратко о том, как считается TF-IDF. TF - это частотность термина, которая измеряет, насколько часто термин встречается в документе. Логично предположить, что в длинных документах термин может встретиться в больших количествах, чем в коротких. Поэтому применяют относительные — делят количество раз, когда нужный термин встретился в тексте, на общее количество слов в тексте. В этом основное отличие от CountVectorizer. IDF — это обратная частотность документов. Она измеряет непосредственно важность термина. То есть, когда мы считаем TF, все термины считаются равными по важности друг другу. Но вполне очевидно, что, например, предлоги встречаются очень часто, хотя практически не влияют на смысл текста. IDF считается как логарифм от общего количества документов, делённого на количество документов, в которых встречается термин. И в конце TF умножается на IDF.Также, TfidfVectorizer имеет множество настроек:- минимальное и максимальное количнсвто n-грамм для анализа- максимальное число признаков- минимальное и максимальное число документов, в которых должно встретиться слово, чтобы попасть в признаки.- отключение расчета IDF- логарифмическое масштабирование значения TF- использование стоп-слов- приведение всех символов к нижнему регистру- нормализация токенови многие другиеИ, пожалуй, один из основных плюсов - TfidfVectorizer имеет встроенный предобработчик текста, который задается в формате RegExp и его можно в любое время переопределить. По умолчанию удаляются знаки пунктуации, символы whitespace и берутся токены не меньше двух символов. Рассмотрим на примере случайного комментария, как TfidfVectorizer предобрабатывает текст.<jupyter_code>text = [train.sample(1).iloc[0, 0]]
text<jupyter_output><empty_output><jupyter_text>Теперь обучим TfidfVectorizer и посмотрим на словарь. Так как комментарий один, то смысла в вычислении IDF нет, поэтому установим значение параметра use_idf в False.<jupyter_code>cv = TfidfVectorizer(use_idf=False)
X = cv.fit_transform(text)
cv.get_feature_names()<jupyter_output><empty_output><jupyter_text>Видно, что TfidfVectorizer отлично справился с задачей. Матрица TF-IDF:<jupyter_code>X.toarray()<jupyter_output><empty_output><jupyter_text>Сопоставим слова с их значениями TF-IDF (в данном случае только TF, так как комментарий один) и отсортируем по значениям TF в порядке убывания.<jupyter_code>matrix_freq = np.asarray(X.sum(axis=0)).ravel()
dictionary = dict(zip(np.array(cv.get_feature_names()), matrix_freq))
pprint.pprint(sorted(dictionary.items(), key=lambda x: x[1], reverse=True))<jupyter_output><empty_output><jupyter_text>Слово "and" встречается чаще всего в комментарии и соответственно имеет наибольший вес. Слова "back", "forth" и "you" также встречаются более одного раза и имеют соответственно бОльшие веса. Слова, встречающиеся один раз в тексте имеют одинаковые веса. Но в вышеприведенном примере обрабатывается только один комментарий. В дальнейшем в словах, которые будут встречаться почти во всех комментариях, множитель IDF будет близок к нулю, что приведет к итоговому уменьшению значений весов у подобных слов. В этом вся прелесть и красота этой метрики.  Преобразуем весь корпус комментариев в обучающей и тестовой выборках. Важно не забывать, что обучать TfidfVectorizer необходимо только на обучающей выборке, потому что в реальной жизни тестовые данные не известны. Если обучить на всем корпусе комментариев, то легко может получиться так, что бОльшие веса будут у слов, которые при обучении только на обучающей выборке таких значений весов бы не получили. Работать будем с униграмами и максимальным значением признаков 30 000.<jupyter_code>%%time
cv = TfidfVectorizer(ngram_range=(1,1), max_features=30000)
X_train = cv.fit_transform(train.comment_text)
X_test = cv.transform(test.comment_text)
print('Размер обучающей матрицы: {}'.format(X_train.shape))
print('Размер тестовой матрицы: {}'.format(X_test.shape))<jupyter_output><empty_output><jupyter_text>Выведем снова значения весов слов для комментария, который рассматривали выше:<jupyter_code>idx = train[train.comment_text.str.contains('k. εαίδεια*')].index
col_idx = np.nonzero(X_train[idx,:].toarray())[1]
row = X_train[idx,:].toarray()
word_values = row[row!=0]
word_list = [list(cv.vocabulary_.keys())[list(cv.vocabulary_.values()).index(x)] for x in col_idx]
dictionary = dict(zip(np.array(word_list), np.array(word_values)))
pprint.pprint(sorted(dictionary.items(), key=lambda x: x[1], reverse=True))<jupyter_output><empty_output><jupyter_text>Ситуация сильно изменилась. Видно, что часто используемые в комментариях (да и в обычной жизни) слова имеют минимальные веса. Обычно, имеет смысл удалять такие слова на этапе препроцессинга. У этих слов есть определенный термин - стоп-слова, и в библиотеке sklearn есть их предопределенный набор. Посмотрим сколько слов содержит стоп-лист:<jupyter_code>len(ENGLISH_STOP_WORDS)<jupyter_output><empty_output><jupyter_text>Добавим их и заново преобразуем корпус комментариев, для этого в TfidfVectorizer есть параметр "stop_words"<jupyter_code>%%time
cv = TfidfVectorizer(ngram_range=(1,1), max_features=30000, stop_words='english')
X_train = cv.fit_transform(train.comment_text)
X_test = cv.transform(test.comment_text)<jupyter_output><empty_output><jupyter_text>Снова посмотрим значения весов слов для вышерассматриваемого комментария:<jupyter_code>idx = train[train.comment_text.str.contains('k. εαίδεια*')].index
col_idx = np.nonzero(X_train[idx,:].toarray())[1]
row = X_train[idx,:].toarray()
word_values = row[row!=0]
word_list = [list(cv.vocabulary_.keys())[list(cv.vocabulary_.values()).index(x)] for x in col_idx]
dictionary = dict(zip(np.array(word_list), np.array(word_values)))
pprint.pprint(sorted(dictionary.items(), key=lambda x: x[1], reverse=True))<jupyter_output><empty_output><jupyter_text>Великолепный результат - из 29 слов осталось 12 значимых. Остался еще один момент - линейные алгоритмы очень любят, чтобы признаки были отмасштабированы. Значение TF-IDF теоретически может быть гораздо больше 1, но в TfidfVectorizer по умолчанию используется L2 нормализация (параметр "norm"), при которой все значения весов масштабируются на интервал [0; 1]. Проверим:<jupyter_code>print('Максимальное значение TF-IDF: {}'.format(X_train.max()))
print('Минимальное значение TF-IDF: {}'.format(X_train.min()))<jupyter_output><empty_output><jupyter_text>Выше рассматривалось, что пропусков в данных и выбросов нет.  Часть 8. Кросс-валидация и настройка гиперпараметров модели В третьей части проекта было показано, что целевая переменная имеет сильный дисбаланс по всем классам. Для кросс-валидации необходимо будет разбить обучающие данные таким образом, чтобы в каждом фолде было примерно одинаковое количество каждого из шести классов. Таким образом, необходимо провести стратифицированный отбор объектов из обучающей выборки по каждому вектору целевой переменной, чтобы в итоговых фолдах было примерно одинаковое количество всех шести классов. В sklearn не реализована такая возможность, поэтому внизу приведена кастомная функция для стратифицированной разбивки данных по фолдам. В данной реализации seed указывать нет необходимости, так как данная несложная задача оптимизации имеет аналитическое решение. Функция принимает на вход векторы целевой переменной (матрицу), количество необходимых фолдов и возвращает индексы объектов для каждого фолда.<jupyter_code>def multi_label_split(y, folds=3):
    obs, classes = y.shape
    dist = y.sum(axis=0).astype('float')
    dist /= dist.sum()
    index_list = []
    fold_dist = np.zeros((folds, classes), dtype='float')
    for _ in range(folds):
        index_list.append([])
    for i in range(obs):
        if i < folds:
            target_fold = i
        else:
            normed_folds = fold_dist.T / fold_dist.sum(axis=1)
            how_off = normed_folds.T - dist
            target_fold = np.argmin(np.dot((y[i] - .5).reshape(1, -1), how_off.T))
        fold_dist[target_fold] += y[i]
        index_list[target_fold].append(i)
    return index_list, fold_dist<jupyter_output><empty_output><jupyter_text>Реализуем трехуровневую кросс-валидацию.<jupyter_code>index_list, fold_dist = multi_label_split(y=y_train, folds=3)
plt.figure(figsize=(18,5))
plt.suptitle('Распределение классов по фолдам ({} объектов)'.format(len(train)),fontsize = 18)

for i, fold in enumerate(fold_dist): 
    plt.subplot(131+i)
    ax1 = sns.barplot(y_cols, fold, color=color[i+2], alpha=0.8)
    ax1.set_title('Фолд {} ({} объектов)'.format(i+1, len(y_train[index_list[i]])), fontsize= 12)
    ax1.set_ylabel('Количество объектов')
    ax1.set_xlabel('Класс')
    for p in ax1.patches:
        x=p.get_bbox().get_points()[:,0]
        y=p.get_bbox().get_points()[1,1]
        ax1.annotate('{:.0f}'.format(p.get_height()), (x.mean(), y), ha='center', va='bottom')
    ax1.grid(False);<jupyter_output><empty_output><jupyter_text>Видно, что количество объектов и распределение классов во всех фолдах примерно одинаковое.Теперь необходимо реализовать функцию кросс-валидации. Здесь стоит остановиться на одном важном моменте. Необходимо будет заново преобразовывать обучающую и валидационную выборки в матрицы TF-IDF при каждом проходе по фолдам, чтобы избежать утечки данных и как следствие некорректного значения на кросс-валидации. На вход функция будет принимать следующие параметры:1. Модель для обучения и прогнозов2. Исходную обучающую выборку (это необходимо для того, чтобы в дальнейшем построить кривые обучения и валидации)3. Целевую переменную4. Список индексов фолдов, полученный в ранее реализованной функции multi_label_split. При каждом проходе один фолд будет выполнять роль тестовой выборки, а два других - обучающей.5. Параметры TfidfVectorizer6. Параметр verbose - отображать или нет значения ROC-AUC классов в каждом фолдеНа выходе функция будет возвращать среднее значение ROC-AUC значений по фолдам. На каждом проходе модель обучается шесть раз на каждом классе целевой переменной и каждый раз рассчитывается значение ROC-AUC для прогнозов по каждому классу, потом эти шесть значений усредняются (макроусреднение, как описывалось выше).<jupyter_code>def multi_label_cv(model, X, y, index_list, tfidf_params, verbose=True, add_feats=False):
    roc_auc_folds = []
    for i in range(3):
        print('========== FOLD {} =========='.format(i+1))
        X_tr = X.values[list(set(range(len(y)))-set(index_list[i])), :]
        y_tr = y[list(set(range(len(y)))-set(index_list[i])), :]
        X_val, y_val = X.values[index_list[i], :], y[index_list[i], :]

        cv = TfidfVectorizer(**tfidf_params)
        X_tr_csr = cv.fit_transform(X_tr.ravel())
        X_val_csr = cv.transform(X_val.ravel())

        roc_auc_scores = []
        preds_val = np.zeros((y_val.shape[0], 6))
        for i in range (6):
            print('class: {}, '.format(y_cols[i]), end=" ")
            model.fit(X_tr_csr, y_tr[:,i])
            preds_val[:,i] = model.predict_proba(X_val_csr)[:,1]
            roc_auc_scores.append(roc_auc_score(y_val[:,i], preds_val[:,i]))
            if verbose:
                print('ROC-AUC score: ', roc_auc_scores[i])

        print('mean ROC-AUC score by fold: ', np.mean(roc_auc_scores))
        roc_auc_folds.append(np.mean(roc_auc_scores))

    print('\nMEAN ROC-AUС score: ', np.mean(roc_auc_folds))<jupyter_output><empty_output><jupyter_text>Гиперпараметры модели Логистическая регрессия (LogisticRegression) имеет следующие основные гиперпараметры:1. solver ("newton-cg", "lbfgs", "liblinear", "sag", "saga") – алгоритм, который используется для решения задачи оптимизации. Для небольших данных обычно используется "liblinear". Остальные алгоритмы используются либо для больших данных, либо для решения задачи оптимизации в случае, когда целевая переменная представляет собой один мультиклассовый вектор (multinomial). В нашем случае используется схема мультиклассовой классификации one-versus-rest, для которой идельно подходит алгоритм liblinear.2. C - коэффициент регуляризации. Чем меньше значение данного коэффициента, тем сильнее регуляризация. Значение данного гиперпараметра будет настроено позже, после после создания новых признаков. В данной части берется значение по умолчанию, равное единице.3. penalty (L1 или L2) - тип регуляризации. По умолчанию используется L2-регуляризация, в то время, как нам, возможно, будет полезен L1-тип регуляризации. Этот гиперпараметр также будет настроен на кросс-валидации после построения новых признаков.4. class_weight - очень важный гиперпараметр в контексте решения данной задачи. Используется при дисбалансе классов. Сразу выставим его значение True.5. random_state - используется для воспроизводимости результатов. Укажем значение равное 1.6. dual - прямая или дуальная задача оптимизации. Иммет смысл использовать только если количество признаков превышает количество объектов. 7. tol, max_iter - параметры раннего останова алгоритма оптимизации, используются только в алгоритмах  newton-cg, sag и lbfgs8. multi_class - multinominal или ovr. В данной задаче реализуется схема мультиклассовой классификации one-versus-rest, что соответствует значению по умолчанию9. fit_intercept - определяет добавлять ли константный коэффициент. По умолчанию True, что соответствует задаче.<jupyter_code>lr_params={'C': 1.0,
           'solver': 'liblinear', 
           'penalty': 'l2',
           'class_weight': 'balanced',
           'random_state': 1,
           }

lr = LogisticRegression(**lr_params)

tfidf_params={'ngram_range': (1,1),
              'max_features': 30000, 
              'stop_words': 'english'
              }
multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params)<jupyter_output><empty_output><jupyter_text>Алгоритм показывает довольно хорошие результаты.В данной части была реализована модель кросс-валидации и выбраны гиперпараметры модели LogisticRegression. Значения параметров "С" и "penalty" будут подобраны на кросс-валидации в следующей части после создания признаков.  Часть 9. Создание новых признаков и описание этого процесса Ранее выдвигались следующие предположения:1. Нормальные комментарии в среднем длиннее оскорбительных, поэтому можно создать признак "длина комментария в символах"2. Довольно часто в реальной жизни при хамском общении люди часто используют капслок в комментариях. Есть даже такой термин "кричать капслоком". На основании этого можно посчитать частоту символов в верхнем регистре относительно общего числа символов в комментарии. Как таковое абсолютное значение числа символов в верхнем регистре будет бессмысленно из-за того, что комментарии имеют разную длину от 1 до 5000 символов.3. Очевидно, что в интернете, когда люди оскорбляют кого-то, то используют восклицательные знаки (аналогично крику в реальной жизни). На основании данного предположения можно посчитать частоту восклицательных знаков в тексте комментария.4. Можно выдвинуть гипотезу, что комментарии, содержащие символ \*, скорее всего оскорбительного характера. Обычно люди не употребляют этот символ при написании текста, но если собираются написать неприличное слово, то какую-нибудь букву в нем скорее всего заменят на этот символ. Либо пользователь догадывается или знает, что комментарии автоматически модерируются и специально поставит \*, чтобы выйти из паттерна.5. Признаковое пространство слов. Можно будет подобрать на кросс-валидации оптимальное число признаков , дающее лучший результат на обучении.После создания новых признаков необходимо будет подобрать гиперпараметры модели LogisticRegression  Признак "Длина комментария в символах"Создадим отдельный датафрейм, который в дальнейшем будет содержать дополнительные добавляемые признаки и создадим признак "длина комментария в символах"<jupyter_code>feats_train = pd.DataFrame()
feats_train['length'] = train['comment_text'].apply(len)
feats_train.head()<jupyter_output><empty_output><jupyter_text>Значения признака находятся в интервале [1, 5000], как было показано ранее. Это не подходит для модели LogisticRegression, поэтому значения необходимо будет отмасштабировать. Причем делать это необходимо будет при каждом проходе по фолдам, чтобы не было утечки данных. Для этого доработаем функцию кросс-валидации. В ней добавятся еще два параметра:- X_f - датафрейм с дополнительными признаками. - scale_cols - масштабируемые признакиДатафрейм с дополнительными признаками также разбивается по индексам и необходимые признаки масштабируются. Далее матрицы объединяются с помощью функции hstack.<jupyter_code>def multi_label_cv(model, X, y, index_list, tfidf_params, X_f, scale_cols, verbose=True):
    roc_auc_folds = []
    for fold in range(3):
        if verbose:
            print('========== FOLD {} =========='.format(fold+1))
        trn_idx = list(set(range(len(y)))-set(index_list[fold]))
        X_tr = X.values[trn_idx, :]
        y_tr = y[trn_idx, :]
        X_val, y_val = X.values[index_list[fold], :], y[index_list[fold], :]
        X_tr_f = X_f.iloc[trn_idx, :]
        X_val_f = X_f.iloc[index_list[fold], :]

        cv = TfidfVectorizer(**tfidf_params)
        X_t_csr = cv.fit_transform(X_tr.ravel())
        X_v_csr = cv.transform(X_val.ravel())  
        
        scaler = StandardScaler()
        X_tr_f[scale_cols] = scaler.fit_transform(X_tr_f[scale_cols].values)
        X_val_f[scale_cols] = scaler.transform(X_val_f[scale_cols].values)
        
        X_tr_csr = hstack([X_t_csr, X_tr_f])
        X_val_csr = hstack([X_v_csr, X_val_f])

        roc_auc_scores = []
        preds_val = np.zeros((y_val.shape[0], 6))
        for i in range (6):
            model.fit(X_tr_csr, y_tr[:,i])
            preds_val[:,i] = model.predict_proba(X_val_csr)[:,1]
            roc_auc_scores.append(roc_auc_score(y_val[:,i], preds_val[:,i]))
            if verbose:
                print('class: {}, '.format(y_cols[i]), end=" ")
                print('ROC-AUC score: ', roc_auc_scores[i])
        if verbose:
            print('mean ROC-AUC score by fold: ', np.mean(roc_auc_scores))
        roc_auc_folds.append(np.mean(roc_auc_scores))

    print('\nMEAN ROC-AUС score: {:.6f}\n'.format(np.mean(roc_auc_folds)))
    return np.mean(roc_auc_folds)
%%time
_ = multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params, X_f=feats_train, 
               scale_cols=['length'], verbose=False)<jupyter_output><empty_output><jupyter_text>Значения ROC-AUC улучшились. Добавим теперь признак "Доля символов в верхнем регистре"  Признак "Доля символов в верхнем регистре в комментарии" Для подсчета доли символов в верхнем регистре удобно воспользоваться библиотекой регулярных выражений re:<jupyter_code>feats_train['upper_case_ratio'] = train['comment_text'].apply(lambda x: \
                                                              len(re.findall(r'[A-Z]',x))/(len(re.findall(r'[A-Za-z]',x))+1))
feats_train.head()<jupyter_output><empty_output><jupyter_text>Очевидно, что данные значения всегда будут меньше или равны единице, следовательно масштабировать данный признак не надо.<jupyter_code>%%time
_ = multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params, X_f=feats_train, 
               scale_cols=['length'], verbose=False)<jupyter_output><empty_output><jupyter_text>Значение метрики снова улучшилось.  Признак "Доля восклицательных знаков"<jupyter_code>feats_train['exclamation_point'] = train['comment_text'].apply(lambda x: x.count('!')/len(x))
feats_train.head()<jupyter_output><empty_output><jupyter_text>В данном случае значения также всегда будут меньше или равны единице, следовательно масштабировать данный признак не надо.<jupyter_code>%%time
_ = multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params, X_f=feats_train, 
               scale_cols=['length'], verbose=False)<jupyter_output><empty_output><jupyter_text>Значение метрики ROC-AUC на кросс-валидации в очередной раз улучшилось.  Признак "Символ * в комментарии" Данный признак будет бинарный:1 - в комментарии присутствует символ\*0 - в комментарии отсутствует символ \*<jupyter_code>feats_train['asterix'] = train['comment_text'].apply(lambda x: 1 if x.count('*')>0 else 0)
feats_train.head()
%%time
_ = multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params, X_f=feats_train, 
               scale_cols=['length'], verbose=False)<jupyter_output><empty_output><jupyter_text>Гипотеза оказалась верна и значение средней величины ROC-AUC на кросс-валидации улучшилось.  Настройка параметров TfidfVectorizer **Признаковое пространство**В цикле будем вызывать функцию кросс-валидации, передавая в TfidfVectorizer различные значения параметра 'max_features' (максимальное число признаков-слов). Параметр будет принимать значения от 10 000 до 100 000 с шагом 10 000.<jupyter_code>%%time
scores=[]
for num_feats in range(10000,110000,10000):
    tfidf_params={'ngram_range': (1,1),
                  'max_features': num_feats, 
                  'stop_words': 'english'
                  }
    print('max_features = {}'.format(num_feats))
    score = multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params, X_f=feats_train, 
                   scale_cols=['length'], verbose=False)    
    scores.append(score)<jupyter_output><empty_output><jupyter_text>Построим график зависимости значения ROC-AUC от числа признаков-слов.<jupyter_code>plt.figure(figsize=(10,6))
plt.plot(list(range(10000,110000,10000)), scores)
plt.ylabel('Среднее ROC-AUC на кросс-валидации', fontsize=12)
plt.xlabel('Количество признаков (max_features)', fontsize=12);<jupyter_output><empty_output><jupyter_text>Хорошо видно, что примерно при 80 000 признаков значение выходит на плато. В дальнейшем будем использовать данное значение для параметра max_features. **Параметр sublinear_tf**Данный параметр преобразует значение TF в логарифмической шкале и рассчитывается по формуле tf = 1 + log(tf), если tf>0. Смысл этого параметра в том, что значение TF быстро растет при маленьких значениях (слово нечасто встречается в документе) и медленно - при больших значениях (слово часто встречается в документе). По умолчанию равен False. Также в цикле будем вызывать функцию кросс-валидации, передавая в TfidfVectorizer различные значения параметра.<jupyter_code>%%time
scores=[]
for param in [True, False]:
    tfidf_params={'ngram_range': (1,1),
                  'max_features': 80000, 
                  'stop_words': 'english',
                  'sublinear_tf': param
                  }
    print('sublinear_tf = {}'.format(param))
    score = multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params, X_f=feats_train, 
                   scale_cols=['length'], verbose=False)    
    scores.append(score)<jupyter_output><empty_output><jupyter_text>При сглаживании значения TF значение ROC-AUC ощутимо повышается.Зафиксируем окончательные значения параметров TfidfVectorizer:<jupyter_code>tfidf_params={'ngram_range': (1,1),
              'max_features': 80000, 
              'stop_words': 'english',
              'sublinear_tf': True
                  }<jupyter_output><empty_output><jupyter_text>Настройка гиперпараметров модели Осталось подобрать значения гиперпараметров модели. Аналогично в цикле будем вызывать функцию кросс-валидации, передавая в неё модель с различными параметрами. Параметры TfidfVectorizer уже подобраны и их не меняем. Для начала сравним значения при рахных регуляризациях (l1 и l2), а также сделаем предварительную оценку лучшего значения коэффициента регуляризации. Более точно лучшее значение выберем в следующей части при построении кривых валидации.<jupyter_code>%%time
scores=[]
for i in ['l2', 'l1']:
       
    lr_params={'C': 1.0,
           'solver': 'liblinear', 
           'penalty': i,
           'class_weight': 'balanced',
           'random_state': 1,
           }
    print('penalty = {}'.format(i))
    lr = LogisticRegression(**lr_params)
    score = multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params, X_f=feats_train, 
                   scale_cols=['length'], verbose=False)    
    scores.append(score)<jupyter_output><empty_output><jupyter_text>Результаты при  использовании l2-регуляризации значительно лучше<jupyter_code>%%time
scores=[]

for i in [0.5, 1.0, 2.0, 3.0, 4.0]:
        
    lr_params={'C': i,
           'solver': 'liblinear', 
           'penalty': 'l2',
           'class_weight': 'balanced',
           'random_state': 1,
           }
    print('С = {}'.format(i))
    lr = LogisticRegression(**lr_params)
    score = multi_label_cv(lr, train, y_train, index_list, tfidf_params=tfidf_params, X_f=feats_train, 
                   scale_cols=['length'], verbose=False)    
    scores.append(score)<jupyter_output><empty_output><jupyter_text>Построим график зависимости значения ROC-AUC от значения коэффициента регуляризации.<jupyter_code>plt.figure(figsize=(10,6))
plt.plot([0.5, 1.0, 2.0, 3.0, 4.0], scores)
plt.ylabel('Среднее ROC-AUC на кросс-валидации', fontsize=12)
plt.xlabel('С', fontsize=12);<jupyter_output><empty_output><jupyter_text>Лучшее значение коэффициента регуляризации С предположительно лежит в интервале (0.5. 2.0)  Часть 10. Построение кривых валидации и обучения Модифицируем функцию кросс-валидации так, чтобы она возвращала следующие значения:- среднее значение ROC-AUC на обучающей выборке- среднее значение ROC-AUC на тестовой выборке- стандартное отклонение значения ROC-AUC на обучающей выборке- стандартное отклонение значения ROC-AUC на тестовой выборке<jupyter_code>def multi_label_cv(model, X, y, index_list, tfidf_params, X_f, scale_cols, verbose=True):
    roc_auc_folds_val = []
    roc_auc_folds_tr = []
    for fold in range(3):
        if verbose:
            print('========== FOLD {} =========='.format(fold+1))
        trn_idx = list(set(range(len(y)))-set(index_list[fold]))
        X_tr = X.values[trn_idx, :]
        y_tr = y[trn_idx, :]
        X_val, y_val = X.values[index_list[fold], :], y[index_list[fold], :]
        X_tr_f = X_f.iloc[trn_idx, :]
        X_val_f = X_f.iloc[index_list[fold], :]

        cv = TfidfVectorizer(**tfidf_params)
        X_t_csr = cv.fit_transform(X_tr.ravel())
        X_v_csr = cv.transform(X_val.ravel())  
        
        scaler = StandardScaler()
        X_tr_f[scale_cols] = scaler.fit_transform(X_tr_f[scale_cols].values)
        X_val_f[scale_cols] = scaler.transform(X_val_f[scale_cols].values)
        
        X_tr_csr = hstack([X_t_csr, X_tr_f])
        X_val_csr = hstack([X_v_csr, X_val_f])

        roc_auc_scores_val = []
        roc_auc_scores_tr = []
        preds_val = np.zeros((y_val.shape[0], 6))
        preds_tr = np.zeros((y_tr.shape[0], 6))
        for i in range (6):
            model.fit(X_tr_csr, y_tr[:,i])
            preds_val[:,i] = model.predict_proba(X_val_csr)[:,1]
            preds_tr[:,i] = model.predict_proba(X_tr_csr)[:,1]
            roc_auc_scores_val.append(roc_auc_score(y_val[:,i], preds_val[:,i]))
            roc_auc_scores_tr.append(roc_auc_score(y_tr[:,i], preds_tr[:,i]))
            if verbose:
                print('class: {}, '.format(y_cols[i]), end=" ")
                print('ROC-AUC score: ', roc_auc_scores[i])
        if verbose:
            print('mean ROC-AUC score by fold: ', np.mean(roc_auc_scores))
        roc_auc_folds_val.append(np.mean(roc_auc_scores_val))
        roc_auc_folds_tr.append(np.mean(roc_auc_scores_tr))

    print('\nMEAN ROC-AUС score: {:.6f}\n'.format(np.mean(roc_auc_folds_val)))
    return np.mean(roc_auc_folds_tr), np.std(roc_auc_folds_tr), np.mean(roc_auc_folds_val), np.std(roc_auc_folds_val)<jupyter_output><empty_output><jupyter_text>Вспомогательные функции для отрисовки кривых валидации и обучения<jupyter_code>def plot_validation_curves(param_values, train_mu, train_std, valid_mu, valid_std):
    train_line = plt.plot(param_values, train_mu, '-', label='train', color='green')
    valid_line = plt.plot(param_values, valid_mu, '-', label='test', color='red')
    plt.fill_between(param_values, np.array(train_mu) - np.array(train_std), 
                     np.array(train_mu) + np.array(train_std), edgecolor='none',
                     facecolor=train_line[0].get_color(), alpha=0.2)
    plt.fill_between(param_values, np.array(valid_mu) - np.array(valid_std), 
                     np.array(valid_mu) + np.array(valid_std), edgecolor='none',
                     facecolor=valid_line[0].get_color(), alpha=0.2)
    plt.legend()
    
def plot_learning_curve(train_sizes, train_mu, train_std, valid_mu, valid_std):
    train_line = plt.plot(train_sizes, train_mu, '-', label='train', color='green')
    valid_line = plt.plot(train_sizes, valid_mu, '-', label='test', color='red')
    plt.fill_between(train_sizes, np.array(train_mu) - np.array(train_std), 
                     np.array(train_mu) + np.array(train_std), edgecolor='none',
                     facecolor=train_line[0].get_color(), alpha=0.2)
    plt.fill_between(train_sizes, np.array(valid_mu) - np.array(valid_std), 
                     np.array(valid_mu) + np.array(valid_std), edgecolor='none',
                     facecolor=valid_line[0].get_color(), alpha=0.2)
    plt.xlabel('Размер обучающей выборки'); 
    plt.ylabel('ROC-AUC')
    plt.legend(loc='lower right');<jupyter_output><empty_output><jupyter_text>Кривые валидации Подберем значение коэффициента регуляризации более точно с помощью кривых валидации. Будем перебирать 20 значений от 0.5 до 2.0. Для каждого значения рассчитываются средние значения ROC-AUC на обучающей и тестовой выборках, а также их стандартные отклонения.<jupyter_code>%%time
mean_scores_train=[]
std_scores_train=[]
mean_scores_val=[]
std_scores_val=[]
Cs = np.linspace(0.5, 2.0, 20)

for i in Cs:
        
    lr_params={'C': i,
           'solver': 'liblinear', 
           'penalty': 'l2',
           'class_weight': 'balanced',
           'random_state': 1,
           }
    print('С = {}'.format(i))
    lr = LogisticRegression(**lr_params)
    mean_score_tr, std_score_tr, mean_score_val, std_score_val = multi_label_cv(lr, train, y_train, 
                        index_list, tfidf_params=tfidf_params, X_f=feats_train, scale_cols=['length'], verbose=False)    
    mean_scores_train.append(mean_score_tr)
    std_scores_train.append(std_score_tr)
    mean_scores_val.append(mean_score_val)
    std_scores_val.append(std_score_val)
print('Лучшее значение ROC AUC = {:.6f}, при С = {:.6f}'.format(max(mean_scores_val), 
                                                                Cs[mean_scores_val.index(max(mean_scores_val))]))<jupyter_output><empty_output><jupyter_text>Построим кривые валидации:<jupyter_code>plt.figure(figsize=(10,6))
Cs_plot = [round(x,2) for x in Cs]
plot_validation_curves(Cs_plot, mean_scores_train, std_scores_train, mean_scores_val, std_scores_val)<jupyter_output><empty_output><jupyter_text>Валидационная кривая:Кривые валидации находятся близко друг другу и имеют высокое значение ROC-AUC в масштабах возможных изменений значений (0.5 - 1). Если бы значение ROC-AUC было бы довольно низкое, в то время как кривые находились бы рядом - это говорило бы о недообучении модели. При переобучении тренировочная и валидационная ошибки значительно бы отличались (не хватало бы регуляризации). В нашем случае при значении коэффициента регуляризации примерно равным 0.736842 модель показывает наилучшие результаты. При увеличении коэффициента (то есть снижении регуляризации, так как коэффициент равен 1/lambda) больше этого значения модель начинает переобучаться. При значениях коэффициента меньше этого значения (увеличение регуляризации) регуляризация избыточно сильная, модель недообучается. Зафиксируем лучшее значение С в параметрах модели:<jupyter_code>lr_params={'C': 0.73684,
           'solver': 'liblinear', 
           'penalty': 'l2',
           'class_weight': 'balanced',
           'random_state': 1,
           }
lr = LogisticRegression(**lr_params)<jupyter_output><empty_output><jupyter_text>Кривые обучения С помощью кривых обучения можно проверить следующую гипотезу - действительно ли у нас достаточно данных. Можно ли улучшить качество модели, взяв больше данных? Это легко проверяется, если значение метрики на валидации сначала повышается и потом в какой-то момент выходит "на плато", то данных достаточно. Если же значение метрики даже при максимально доступном размере данных продолжает увеличиваться, то данных явно недостаточно для выявления закономерностей. В данном случае необходимо увеличить размер обучающей выборки, если это возможно. Для построения кривых обучения используется ранее написанная функция multi_label_split. Минимальный размер выборки - 5% (примерно 5000 комментариев) от обучающей выборки, максимальный размер - вся обучающая выборка. На каждом проходе выборка разбивается на три фолда. таким образом, размер обучающей выборки определяется как произведение размера текущей выборки на 2/3. Также берется 18 промежуточных размеров.<jupyter_code>%%time
mean_scores_train=[]
std_scores_train=[]
mean_scores_val=[]
std_scores_val=[]
n_trains=[]
train_sizes = np.linspace(0.05, 1, 20)
n_train = len(y_train)*train_sizes

for num, size in enumerate(train_sizes):
    
    n_trains.append(int(n_train[num]))
    print('Размер выборки = {}'.format(int(n_trains[num]/3*2)))
    index_list, fold_dist = multi_label_split(y=y_train[:n_trains[num], :], folds=3)
    mean_score_tr, std_score_tr, mean_score_val, std_score_val = multi_label_cv(lr, train, y_train, 
                        index_list, tfidf_params=tfidf_params, X_f=feats_train, scale_cols=['length'], verbose=False)    
    mean_scores_train.append(mean_score_tr)
    std_scores_train.append(std_score_tr)
    mean_scores_val.append(mean_score_val)
    std_scores_val.append(std_score_val)
plt.figure(figsize=(10,6))
plot_learning_curve([int(x/3*2) for x in n_train.tolist()], mean_scores_train, std_scores_train, mean_scores_val, std_scores_val)<jupyter_output><empty_output><jupyter_text>Видно, что в какой-то момент кривые сходятся и кривая валидации тестовой выборки выходит на плато. Данных явно достаточно для выявления закономерностей. Увеличение размера выборки не даст ощутимого прироста качества.  Часть 11. Прогноз для тестовой выборки Сделаем прогноз для тестовой выборки. Для этого загрузим образец файла для прогнозных значений и в соответствующем формате сохраним наши прогнозы. После этого загрузим файл с прогнозами на Kaggle. Результат на LB будет значением ROC-AUC для тестовой выборки. Для прогноза будем использовать гиперпараметры настроенные на кросс-валидации и такое же признаковое пространство.<jupyter_code>submission = pd.read_csv('data/sample_submission.csv')
feats_test = pd.DataFrame()
feats_test['length'] = test['comment_text'].apply(len)
feats_test['upper_case_ratio'] = test['comment_text'].apply(lambda x: \
                                                            len(re.findall(r'[A-Z]',x))/(len(re.findall(r'[A-Za-z]',x))+1))
feats_test['exclamation_point'] = test['comment_text'].apply(lambda x: x.count('!')/len(x))
feats_test['asterix'] = test['comment_text'].apply(lambda x: 1 if x.count('*')>0 else 0)
tfidf_params={'ngram_range': (1,1),
              'max_features': 80000, 
              'stop_words': 'english',
              'sublinear_tf': True
                  }
lr_params={'C': 0.73684,
           'solver': 'liblinear', 
           'penalty': 'l2',
           'class_weight': 'balanced',
           'random_state': 1,
           }
lr = LogisticRegression(**lr_params)
cv = TfidfVectorizer(**tfidf_params)

X_train_csr = cv.fit_transform(train['comment_text'])
X_test_csr = cv.transform(test['comment_text'])  
        
scaler = StandardScaler()
feats_train['length'] = scaler.fit_transform(feats_train[['length']])
feats_test['length'] = scaler.transform(feats_test[['length']])
        
X_train = hstack([X_train_csr, feats_train.values])
X_test = hstack([X_test_csr, feats_test.values])

preds = np.zeros((test.shape[0], 6))
for i in range (6):
    lr.fit(X_train, y_train[:,i])
    preds[:,i] = lr.predict_proba(X_test)[:,1]

submission[["toxic", "severe_toxic", "obscene", "threat", "insult", "identity_hate"]] = preds
submission.to_csv('submission.csv', index=False)<jupyter_output><empty_output><jupyter_text>Результат на LB:<jupyter_code>im = Image.open('data/place.png')
im<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.09779308933383361, 'fraction_numerical': 0.012347784561858307, 'mean_word_length': 5.243744010222554, 'pattern_counts': {'":': 4, '<': 216, '<?xml version=': 0, '>': 217, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 6, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '53', 'dir': 'jupyter-structured-clean-dedup', 'id': '251507', 'license': 'Unlicense', 'n_tokens_mistral': 23174, 'n_tokens_neox': 25864, 'n_words': 5214, 'path': 'jupyter_russian/projects_individual/project_jigsaw_petukhov_dmitrii.ipynb', 'repo_name': 'salman394/AI-ml--course', 'size': '58643'}