Давно я не писал никаких статей и, вот думаю, пришло время написать о там, как мне пригодились знания по data science, полученные по ходу обучения небезывестной специализации от Яндекса и МФТИ «Машинное обучение и анализ данных». Правда, справедливости ради надо отметить, что знания до конца не получены — спецуха не завершена :) Однако, решать простенькие реальные бизнесовые задачи уже можно. Или нужно? На этот вопрос будет ответ, буквально через пару абзацев.
Итак, сегодня в этой статье я расскажу уважаемому читателю о своем первом опыте участия в открытом соревновании. Хотелось бы сразу отметить, что моей целью соревнования было не получение каких-либо призовых мест. Единственное желание было попробовать свои силы в реальном мире :) Да, в добавок так вышло, что тематика соревнования практически никак не пересекалась с материалом из пройденных курсов. Это добавило некоторые сложности, но с этим соревнование стало еще интереснее и ценнее опыт вынесенный оттуда.
По сложившейся традиции, обозначу кому может быть интересна статья. Во-первых, если Вы уже прошли первые два курса указанной выше специализации, и хотите попробовать свои силы на практических задачах, но стесняетесь и переживаете, что может не получиться и Вас засмеют и т.д. После прочтения статьи, такие опасения, надеюсь, развеятся. Во-вторых, возможно, Вы решаете схожую задачу и совсем не знаете с чего зайти. А здесь готовенький простенький, как говорят настоящие датасайнтисты, бэйзлайн :)
Здесь следовало бы уже изложить план исследования, но мы немного отвлечемся и попробуем ответить на вопрос из первого абзаца — нужно ли новичку в датасайнсе пробовать свои силы в подобных соревнованиях. Мнения на этот счет расходятся. Лично мое мнение — нужно! Объясню почему. Причин много, все перечислять не буду, укажу наиболее важные. Во-первых, подобные соревнования помогают закрепить теоретические знания на практике. Во-вторых, на моей практике, почти всегда, опыт, полученный в условиях приближенных к боевым, очень сильно мотивирует на дальнейшие подвиги. В-третьих, и это самое главное — во время соревнований у Вас появляется возможность пообщаться с другими участниками в специальных чатах, можно даже не общаться, можно просто почитать, о чем пишут люди и это а) частенько наводит на интересные мысли о том, какие еще изменения провести в исследовании и б) придает уверенность в подтверждении своих собственных идей, особенно, если они высказываются в чате. К этим плюсам нужно подходить с определенной предусмотрительностью, чтобы не возникло ощущения всезнания…
Теперь немного о том, как я решился на участие. О соревновании я узнал буквально за несколько дней до его начала. Первая мысль «ну если бы я знал о соревновании месяц назад, то подготовился бы, по изучал бы какие-нибудь дополнительные материалы, которые могли бы пригодиться для проведения исследования, а так, без подготовки могу не уложиться в сроки...», вторая мысль «собственно, что может не получиться, если целью является не приз, а участие, тем более, что участники в 95% случаях говорят на русском языке, плюс есть специальные чаты для обсуждения, будут какие-то вебинары от организаторов. В конце концов, можно будет вживую увидеть настоящих датасайнтистов всех мастей и размеров...». Как вы догадались, вторая мысль победила, и не зря — буквально несколько дней плотной работы и я получил ценнейший опыт пусть и простенькой, но вполне себе бизнесовой задачи. Поэтому, если Вы на пути покорения вершин науки о данных и видите намечающееся соревнование, да на родном языке, с поддержкой в чатах и, у Вас есть свободное время — не задумывайтесь надолго — пробуйте и да прибудет с Вами сила! На мажорной ноте мы переходим к задаче и плану исследования.
Сопоставление названий
Не будем мучить себя и придумывать описание задачи, а приведем оригинальный текст с вэб страницы организатора соревнования.
Задача
При поиске новых клиентов СИБУРу приходится обрабатывать информацию о миллионах новых компаний из различных источников. Названия компаний при этом могут иметь разное написание, содержать сокращения или ошибки, быть аффилированными с компаниями, уже известными СИБУРу.
Для более эффективной обработки информации о потенциальных клиентах, СИБУРу необходимо знать, связаны ли два названия (т.е. принадлежат одной компании или аффилированным компаниям).
В этом случае СИБУР сможет использовать уже известную информацию о самой компании или об аффилированных компаниях, не дублировать обращения в компанию или не тратить время на нерелевантные компании или дочерние компании конкурентов.
Тренировочная выборка содержит пары названий из разных источников (в том числе, пользовательских) и разметку.
Разметка получена частично вручную, частично — алгоритмически. Кроме того, разметка может содержать ошибки. Вам предстоит построить бинарную модель, предсказывающую, являются ли два названия связанными. Метрика, используемая в данной задаче — F1.
В этой задаче можно и даже нужно пользоваться открытыми источниками данных для обогащения датасета или поиска дополнительной важной для определения аффилированных компаний информации.
Дополнительная информация о задаче
Названия компаний могут писаться на разных языках: тренировочная и тестовая выборки содержат названия компаний на русском, английском и китайском языках.
В названиях могут присутствовать сокращения, опечатки и дополнительная информация о компании, например, названия стран и провинций.
Публичная (50%) и приватная (50%) части тестового множества не пересекаются.
Правила использования внешних источников
Безвозмездность Источник должен быть бесплатен для всех. Например, нельзя пользоваться данными, к которым у вас есть корпоративный доступ.
Верифицируемость У организаторов должна быть возможность воспроизвести ваш способ сбора данных за 1 день для выборки в 1 000 000 уникальных компаний.
На практике количество проверяемых компаний намного больше данных в рамках соревнования (миллионы компаний). К тому же, одним и тем же способом сбора данных могут пользоваться сразу несколько участников и в случае строгих лимитов мы не сможем верифицировать решение.
Публичность Источник должен быть заявлен в чате до 24:00 6 декабря 2020 с хэштегом #внешниеданные и одобрен организаторами.
Добросовестность Способ, который вы используете при работе с источником, и происхождение данных не должны нарушать законы РФ и правила, которые установлены оператором источника.
Если, например, автоматический парсинг какого-либо сайта запрещен владельцами сайта, то вы можете пользоваться им только вручную.
Источник должен допускать коммерческое использование.
Если для использования источника нужно зарегистрироваться, но в остальном противоречий правилам нет, то его можно использовать.
Один участник может заявить не более 10 источников информации.
Использование API поисковых систем, к сожалению, противоречит пункту 2.
Правила относительно строгих замен, ручной разметки и данных, собранных вручную, в т.ч. с использованием crowdsource
Все “ручные” данные должны быть собраны участниками команды без использования crowdsource платформ и аналогичных методов. Вряд ли мы сможем это проверить, но давайте играть честно:)
Замены общего характера можно использовать без ограничений, если при необходимости вы можете объяснить их происхождение.
Это касается legel entities, стран, городов и т.д. Например, исключение слова Industries из всех названий допустима.
Замена значимых элементов названия компании возможна только на основании внешних данных. Ручная замена не разрешается.
Нельзя использовать строгие правила сравнения названий, использующие значимые элементы. Например, нельзя проверять вхождение подстроки “Сибур” в каждый элемент пары и на основании этого вычислять целевую переменную.
Все данные, использованные при решении задачи, должны опираться только на обучающую выборку и данные, полученные из разрешенных внешних источников. Использовать данные, полученные при анализе тестовой выборки, нельзя.
В случае, если вы используете собственноручно собранные словари, вам нужно описать воспроизводимую логику их составления.
Использование open source
Вы можете пользоваться любыми open source инструментами, выпущенными под разрешительными лицензиями. Если инструмент явным образом включает в себя словари замены или аналогичные данные — о нем нужно сообщить в чате соревнования или в чате задачи в соответствии с правилами для внешних источников. В противном случае сообщать о таком инструменте не нужно.
Если Вы сомневаетесь в том, можно ли использовать какой-то конкретный источник – просто спросите в чате. Возможно, нам придется в будущем расширить этот список, если будет найден какой-то очевидно нечестный по отношении к другим участникам способ, который, тем не менее, соответствует этим правилам.
Данные
train.csv — тренировочное множество
test.csv — тестовое множество
sample_submission.csv — пример решения в правильном формате
Naming baseline.ipynb — код базовое решение
baseline_submission.csv — базовое решение
Обратите внимание, организаторы конкурса позаботились о подрастающем поколении и выложили базовое решение задачи, которое дает качество по f1 около 0.1. Я первый раз участвую в соревнованиях и первый раз такое вижу :)
Итак, ознакомившись с самой задачей и требованиями по ее решению, перейдем к плану решения.
План решения задачи
Настройка технических инструментов
Загрузим библиотеки
Напишем вспомогательные функции
Предобработка данных
Загрузим данные
Посмотрим на данные и сделаем копии
Переведем все символы из текста в нижний регистр
Удалим названия стран
Удалим знаки и спецсимволы
Удалим цифры
Удалим… первый список стоп-слов. Вручную!
Проведем транслитерацию русского текста в латиницу
Запустим автоматическое составление списка топ 50 самых часто встречающихся слов & Drop it smart. Первый ЧИТ
Генерация и анализ фич
Посчитаем дистанцию Левенштейна
Посчитаем нормированную дистанцию Левенштейна
Визуализируем фичи
Сопоставим слова в тексте для каждой пары и сгенерим большую кучу признаков
Сопоставим слова из текста со словами из названий топ 50 холдинговых брендов нефтехимической, строительной отраслей. Получим вторую большую кучу признаков. Второй ЧИТ
Готовим данные для подачи в модель
Настройка и обучение модели
Итоги соревнования
Источники информации
Теперь, когда мы ознакомились с планом проведения исследования, перейдем к его реализации.
Настройка технических инструментов
Загрузка библиотек
Собственно здесь все просто, для начала установим недостающие библиотеки
pip install pycountry
pip install strsimpy
pip install cyrtranslit
import pandas as pd
import numpy as np
import warnings
warnings.filterwarnings('ignore')
import pycountry
import re
from tqdm import tqdm
tqdm.pandas()
from strsimpy.levenshtein import Levenshtein
from strsimpy.normalized_levenshtein import NormalizedLevenshtein
import matplotlib.pyplot as plt
from matplotlib.pyplot import figure
import seaborn as sns
sns.set()
sns.set_style("whitegrid")
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import StratifiedShuffleSplit
from scipy.sparse import csr_matrix
import lightgbm as lgb
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.metrics import recall_score
from sklearn.metrics import precision_score
from sklearn.metrics import roc_auc_score
from sklearn.metrics import classification_report, f1_score
# import googletrans
# from googletrans import Translator
import cyrtranslit
Напишем вспомогательные функции
Хорошим тоном считается вместо копи паста большого куска кода указывать функцию в одну строчку. Мы так и будем делать, почти всегда.
Утверждать, что качество кода в функциях отлиное не буду. Местами его точно следует оптимизировать, но в целях быстрого исследования, достаточно будет только точности расчетов.
Итак, первая функция переводит текст в нижний регистр
# convert text to lowercase
def lower_str(data,column):
data[column] = data[column].str.lower()
Следующие четыре функции помогают визуализировать пространство исследуемых признаков и их способность разделять объекты по целевым меткам — 0 или 1.
# statistic table for analyse float values (it needs to make histogramms and boxplots)
def data_statistics(data,analyse,title_print):
data0 = data[data['target']==0][analyse]
data1 = data[data['target']==1][analyse]
data_describe = pd.DataFrame()
data_describe['target_0'] = data0.describe()
data_describe['target_1'] = data1.describe()
data_describe = data_describe.T
if title_print == 'yes':
print ('\033[1m' + 'Дополнительные статистики по признаку',analyse,'\033[m')
elif title_print == 'no':
None
return data_describe
# histogramms for float values
def hist_fz(data,data_describe,analyse,size):
print ()
print ('\033[1m' + 'Information about',analyse,'\033[m')
print ()
data_0 = data[data['target'] == 0][analyse]
data_1 = data[data['target'] == 1][analyse]
min_data = data_describe['min'].min()
max_data = data_describe['max'].max()
data0_mean = data_describe.loc['target_0']['mean']
data0_median = data_describe.loc['target_0']['50%']
data0_min = data_describe.loc['target_0']['min']
data0_max = data_describe.loc['target_0']['max']
data0_count = data_describe.loc['target_0']['count']
data1_mean = data_describe.loc['target_1']['mean']
data1_median = data_describe.loc['target_1']['50%']
data1_min = data_describe.loc['target_1']['min']
data1_max = data_describe.loc['target_1']['max']
data1_count = data_describe.loc['target_1']['count']
print ('\033[4m' + 'Analyse'+ '\033[m','No duplicates')
figure(figsize=size)
sns.distplot(data_0,color='darkgreen',kde = False)
plt.scatter(data0_mean,0,s=200,marker='o',c='dimgray',label='Mean')
plt.scatter(data0_median,0,s=250,marker='|',c='black',label='Median')
plt.legend(scatterpoints=1,
loc='upper right',
ncol=3,
fontsize=16)
plt.xlim(min_data, max_data)
plt.show()
print ('Quantity:', data0_count,
' Min:', round(data0_min,2),
' Max:', round(data0_max,2),
' Mean:', round(data0_mean,2),
' Median:', round(data0_median,2))
print ()
print ('\033[4m' + 'Analyse'+ '\033[m','Duplicates')
figure(figsize=size)
sns.distplot(data_1,color='darkred',kde = False)
plt.scatter(data1_mean,0,s=200,marker='o',c='dimgray',label='Mean')
plt.scatter(data1_median,0,s=250,marker='|',c='black',label='Median')
plt.legend(scatterpoints=1,
loc='upper right',
ncol=3,
fontsize=16)
plt.xlim(min_data, max_data)
plt.show()
print ('Quantity:', data_1.count(),
' Min:', round(data1_min,2),
' Max:', round(data1_max,2),
' Mean:', round(data1_mean,2),
' Median:', round(data1_median,2))
# draw boxplot
def boxplot(data,analyse,size):
print ('\033[4m' + 'Analyse'+ '\033[m','All pairs')
data_0 = data[data['target'] == 0][analyse]
data_1 = data[data['target'] == 1][analyse]
figure(figsize=size)
sns.boxplot(x=analyse,y='target',data=data,orient='h',
showmeans=True,
meanprops={"marker":"o",
"markerfacecolor":"dimgray",
"markeredgecolor":"black",
"markersize":"14"},
palette=['palegreen', 'salmon'])
plt.ylabel('target', size=14)
plt.xlabel(analyse, size=14)
plt.show()
# draw graph for analyse two choosing features for predict traget label
def two_features(data,analyse1,analyse2,size):
fig = plt.subplots(figsize=size)
x0 = data[data['target']==0][analyse1]
y0 = data[data['target']==0][analyse2]
x1 = data[data['target']==1][analyse1]
y1 = data[data['target']==1][analyse2]
plt.scatter(x0,y0,c='green',marker='.')
plt.scatter(x1,y1,c='black',marker='+')
plt.xlabel(analyse1)
plt.ylabel(analyse2)
title = [analyse1,analyse2]
plt.title(title)
plt.show()
Пятая функция предназначена для формирования таблицы угадываний и ошибок алгоритма, более известной как таблица сопряжения.
Иными словами, после формирования вектора прогнозов, нам потребуется сопоставить прогноз с целевыми метками. Результатом такого сопоставления должна получиться таблица сопряжения для каждой пары компаний из обучающей выборки. В таблице сопряжения для каждой пары будет определен результат соответствия прогноза к классу из обучающей выборки. Классификация соответствия принята такой: 'True positive', 'False positive', 'True negative' или 'False negative'. Эти данные очень важны для анализа работы алгоритма и принятия решений по доработке модели и признакового пространства.
def contingency_table(X,features,probability_level,tridx,cvidx,model):
tr_predict_proba = model.predict_proba(X.iloc[tridx][features].values)
cv_predict_proba = model.predict_proba(X.iloc[cvidx][features].values)
tr_predict_target = (tr_predict_proba[:, 1] > probability_level).astype(np.int)
cv_predict_target = (cv_predict_proba[:, 1] > probability_level).astype(np.int)
X_tr = X.iloc[tridx]
X_cv = X.iloc[cvidx]
X_tr['predict_proba'] = tr_predict_proba[:,1]
X_cv['predict_proba'] = cv_predict_proba[:,1]
X_tr['predict_target'] = tr_predict_target
X_cv['predict_target'] = cv_predict_target
# make true positive column
data = pd.DataFrame(X_tr[X_tr['target']==1][X_tr['predict_target']==1]['pair_id'])
data['True_Positive'] = 1
X_tr = X_tr.merge(data,on='pair_id',how='left')
data = pd.DataFrame(X_cv[X_cv['target']==1][X_cv['predict_target']==1]['pair_id'])
data['True_Positive'] = 1
X_cv = X_cv.merge(data,on='pair_id',how='left')
# make false positive column
data = pd.DataFrame(X_tr[X_tr['target']==0][X_tr['predict_target']==1]['pair_id'])
data['False_Positive'] = 1
X_tr = X_tr.merge(data,on='pair_id',how='left')
data = pd.DataFrame(X_cv[X_cv['target']==0][X_cv['predict_target']==1]['pair_id'])
data['False_Positive'] = 1
X_cv = X_cv.merge(data,on='pair_id',how='left')
# make true negative column
data = pd.DataFrame(X_tr[X_tr['target']==0][X_tr['predict_target']==0]['pair_id'])
data['True_Negative'] = 1
X_tr = X_tr.merge(data,on='pair_id',how='left')
data = pd.DataFrame(X_cv[X_cv['target']==0][X_cv['predict_target']==0]['pair_id'])
data['True_Negative'] = 1
X_cv = X_cv.merge(data,on='pair_id',how='left')
# make false negative column
data = pd.DataFrame(X_tr[X_tr['target']==1][X_tr['predict_target']==0]['pair_id'])
data['False_Negative'] = 1
X_tr = X_tr.merge(data,on='pair_id',how='left')
data = pd.DataFrame(X_cv[X_cv['target']==1][X_cv['predict_target']==0]['pair_id'])
data['False_Negative'] = 1
X_cv = X_cv.merge(data,on='pair_id',how='left')
return X_tr,X_cv
Шестая функция предназначена для формирования матрицы сопряжения. Не путайте с таблицей сопряжения. Хотя одно следует из другого. Вы сами все увидите дальше
def matrix_confusion(X):
list_matrix = ['True_Positive','False_Positive','True_Negative','False_Negative']
tr_pos = X[list_matrix].sum().loc['True_Positive']
f_pos = X[list_matrix].sum().loc['False_Positive']
tr_neg = X[list_matrix].sum().loc['True_Negative']
f_neg = X[list_matrix].sum().loc['False_Negative']
matrix_confusion = pd.DataFrame()
matrix_confusion['0_algorythm'] = np.array([tr_neg,f_neg]).T
matrix_confusion['1_algorythm'] = np.array([f_pos,tr_pos]).T
matrix_confusion = matrix_confusion.rename(index={0: '0_target', 1: '1_target'})
return matrix_confusion
Седьмая функция предназначена для визуализации отчета о работе алгоритма, который включает в себя матрицу сопряжения, значения метрик precision, recall, f1
def report_score(tr_matrix_confusion,
cv_matrix_confusion,
data,tridx,cvidx,
X_tr,X_cv):
# print some imporatant information
print ('\033[1m'+'Matrix confusion on train data'+'\033[m')
display(tr_matrix_confusion)
print ()
print(classification_report(data.iloc[tridx]["target"].values, X_tr['predict_target']))
print ('******************************************************')
print ()
print ()
print ('\033[1m'+'Matrix confusion on test(cv) data'+'\033[m')
display(cv_matrix_confusion)
print ()
print(classification_report(data.iloc[cvidx]["target"].values, X_cv['predict_target']))
print ('******************************************************')
С помощью восьмой и девятой функции проведем анализ на полезность признаков для используемой модели из Light GBM с точки зрения значения коэффициента 'Information gain' для каждого исследуемого признака
def table_gain_coef(model,features,start,stop):
data_gain = pd.DataFrame()
data_gain['Features'] = features
data_gain['Gain'] = model.booster_.feature_importance(importance_type='gain')
return data_gain.sort_values('Gain', ascending=False)[start:stop]
def gain_hist(df,size,start,stop):
fig, ax = plt.subplots(figsize=(size))
x = (df.sort_values('Gain', ascending=False)['Features'][start:stop])
y = (df.sort_values('Gain', ascending=False)['Gain'][start:stop])
plt.bar(x,y)
plt.xlabel('Features')
plt.ylabel('Gain')
plt.xticks(rotation=90)
plt.show()
Десятая функция нужна для формирования массива количества совпадающих слов для каждой пары компаний.
Эту функцию также можно использовать для формирования массива НЕ совпадающих слов.
def compair_metrics(data):
duplicate_count = []
duplicate_sum = []
for i in range(len(data)):
count=len(data[i])
duplicate_count.append(count)
if count <= 0:
duplicate_sum.append(0)
elif count > 0:
temp_sum = 0
for j in range(len(data[i])):
temp_sum +=len(data[i][j])
duplicate_sum.append(temp_sum)
return duplicate_count,duplicate_sum
Одинадцатая функция проводит транслитерацию русского текста в латиницу
def transliterate(data):
text_transliterate = []
for i in range(data.shape[0]):
temp_list = list(data[i:i+1])
temp_str = ''.join(temp_list)
result = cyrtranslit.to_latin(temp_str,'ru')
text_transliterate.append(result)
Двенадцатая функция нужна для переименования столбцов таблицы после агрегирования данных.
Дело в том, что после агрегации данных, названия столбцов, как бы распадаются на два уровня. В итоге, для приведения таблицы к принятому в исследовании формату, используем самописную функцию
<spoiler title="Код">
<source lang="python">def rename_agg_columns(id_client,data,rename):
columns = [id_client]
for lev_0 in data.columns.levels[0]:
if lev_0 != id_client:
for lev_1 in data.columns.levels[1][:-1]:
columns.append(rename % (lev_0, lev_1))
data.columns = columns
return data
return text_transliterate
Тринадцатая и четырнадцатая функции нужны для просмотра и формирования таблицы дистанции Левенштейна и других важных показателей.
Что это вообще за таблица, какие в ней метрики и как она формируется? Давайте рассмотрим пошагово формирование таблицы:
- Шаг 1. Определим какие данные нам будут нужны. ID пары, финишная обработка текста — оба столбца, список названий холдингов (топ 50 компаний нефтехимической и строительной индустрии).
- Шаг 2. В столбце 1 в каждой паре от каждого слова замерим дистанцию Левенштейна до каждого слова из списка названий холдингов, а также длину каждого слова и отношение дистанции к длине.
- Шаг 3. В случае, если значение отношения окажется меньше или равно 0.4, то от сравниваемого слова из списка названий топ холдингов определим дистанцию до каждого слова из второго столбца соответствующей id пары, а также длину каждого из слов и отношение дистанции к длине.
- Шаг 4. В случае, если в очередной раз отношение оказывается меньше или равно 0.4, то все собранные метрики фиксируются.
- Шаг 5. По завершению алгоритма будет сформирована таблица, в которой первый столбец ID пары, а последующие столбцы — метрики. Данные в таблице необходимо агрегировать по id пары (так как могут быть случаи сильного соответствия слов из одной id пары двум названиям холдингов). При агрегировании данных выбираем минимальные значения.
- Шаг 6. Склеиваем полученную таблицу с таблицей исследования.
Важная особенность:
расчет занимает продолжительное время из-за написанного на скорую руку кода
def dist_name_to_top_list_view(data,column1,column2,list_top_companies):
id_pair = []
r1 = []
r2 = []
words1 = []
words2 = []
top_words = []
for n in range(0, data.shape[0], 1):
for line1 in data[column1][n:n+1]:
line1 = line1.split()
for word1 in line1:
if len(word1) >=3:
for top_word in list_top_companies:
dist1 = levenshtein.distance(word1, top_word)
ratio = max(dist1/float(len(top_word)),dist1/float(len(word1)))
if ratio <= 0.4:
ratio1 = ratio
break
if ratio <= 0.4:
for line2 in data[column2][n:n+1]:
line2 = line2.split()
for word2 in line2:
dist2 = levenshtein.distance(word2, top_word)
ratio = max(dist2/float(len(top_word)),dist2/float(len(word2)))
if ratio <= 0.4:
ratio2 = ratio
id_pair.append(int(data['pair_id'][n:n+1].values))
r1.append(ratio1)
r2.append(ratio2)
break
df = pd.DataFrame()
df['pair_id'] = id_pair
df['levenstein_dist_w1_top_w'] = dist1
df['levenstein_dist_w2_top_w'] = dist2
df['length_w1_top_w'] = len(word1)
df['length_w2_top_w'] = len(word2)
df['length_top_w'] = len(top_word)
df['ratio_dist_w1_to_top_w'] = r1
df['ratio_dist_w2_to_top_w'] = r2
feature = df.groupby(['pair_id']).agg([min]).reset_index()
feature = rename_agg_columns(id_client='pair_id',data=feature,rename='%s_%s')
data = data.merge(feature,on='pair_id',how='left')
display(data)
print ('Words:', word1,word2,top_word)
print ('Levenstein distance:',dist1,dist2)
print ('Length of word:',len(word1),len(word2),len(top_word))
print ('Ratio (distance/length word):',ratio1,ratio2)
def dist_name_to_top_list_make(data,column1,column2,list_top_companies):
id_pair = []
r1 = []
r2 = []
dist_w1 = []
dist_w2 = []
length_w1 = []
length_w2 = []
length_top_w = []
for n in range(0, data.shape[0], 1):
for line1 in data[column1][n:n+1]:
line1 = line1.split()
for word1 in line1:
if len(word1) >=3:
for top_word in list_top_companies:
dist1 = levenshtein.distance(word1, top_word)
ratio = max(dist1/float(len(top_word)),dist1/float(len(word1)))
if ratio <= 0.4:
ratio1 = ratio
break
if ratio <= 0.4:
for line2 in data[column2][n:n+1]:
line2 = line2.split()
for word2 in line2:
dist2 = levenshtein.distance(word2, top_word)
ratio = max(dist2/float(len(top_word)),dist2/float(len(word2)))
if ratio <= 0.4:
ratio2 = ratio
id_pair.append(int(data['pair_id'][n:n+1].values))
r1.append(ratio1)
r2.append(ratio2)
dist_w1.append(dist1)
dist_w2.append(dist2)
length_w1.append(float(len(word1)))
length_w2.append(float(len(word2)))
length_top_w.append(float(len(top_word)))
break
df = pd.DataFrame()
df['pair_id'] = id_pair
df['levenstein_dist_w1_top_w'] = dist_w1
df['levenstein_dist_w2_top_w'] = dist_w2
df['length_w1_top_w'] = length_w1
df['length_w2_top_w'] = length_w2
df['length_top_w'] = length_top_w
df['ratio_dist_w1_to_top_w'] = r1
df['ratio_dist_w2_to_top_w'] = r2
feature = df.groupby(['pair_id']).agg([min]).reset_index()
feature = rename_agg_columns(id_client='pair_id',data=feature,rename='%s_%s')
data = data.merge(feature,on='pair_id',how='left')
return data
Предобработка данных
Из моего небольшого опыта именно предобработка данных в широком смысле этого выражения занимает большее время. Пойдем по порядку.
Загрузим данные
Здесь все очень просто. Загрузим данные и заменим название столбца с целевой меткой «is_duplicate» на «target». Это делается для удобства использования функций — некоторые из них были написаны в рамках более ранних исследований и они используют название столбца с целевой меткой как «target».
# DOWNLOAD DATA
text_train = pd.read_csv('train.csv')
text_test = pd.read_csv('test.csv')
# RENAME DATA
text_train = text_train.rename(columns={"is_duplicate": "target"})
Посмотрим на данные
Данные загрузили. Давайте посмотрим сколько всего объектов и насколько они сбалансированны.
# ANALYSE BALANCE OF DATA
target_1 = text_train[text_train['target']==1]['target'].count()
target_0 = text_train[text_train['target']==0]['target'].count()
print ('There are', text_train.shape[0], 'objects')
print ('There are', target_1, 'objects with target 1')
print ('There are', target_0, 'objects with target 0')
print ('Balance is', round(100*target_1/target_0,2),'%')
Таблица №1 «Баланс меток»
Объектов не мало — почти 500 тысяч и они вообще никак не сбалансированы. То есть из почти 500 тысяч объектов, всего менее 4 тысяч — имеют целевую метку 1 (менее 1%)
Давайте посмотрим на саму таблицу. Посмотрим на первые пять объектов с разметкой 0 и первые пять объектов с разметкой 1.
display(text_train[text_train['target']==0].head(5))
display(text_train[text_train['target']==1].head(5))
Таблица №2 «Первые 5 объектов класса 0», таблица №3 «Первые 5 объектов класса 1»
Сразу напрашиваются некоторые простые шаги: привести текст к одному регистру, убрать всякие стоп-слова, типа 'ltd', удалить страны и заодно названия географических объектов.
Собственно, примерно так в этой задаче может проходить решение — делаешь какую-нибудь предобработку, убеждаешься в том, что она работает как надо, запускаешь модель, смотришь качество и анализируешь выборочно объекты, на которых модель ошибается. Именно так я и проводил исследование. Но в самой статье дано итоговое решение и качество работы алгоритма после каждой предобработки не разбирается, в конце статьи мы проведем итоговый разбор. Иначе статья была бы неописуемых размеров :)
Сделаем копии
Если честно, то не знаю зачем я это делаю, но почему-то всегда это делаю. Сделаю и в этот раз
baseline_train = text_train.copy()
baseline_test = text_test.copy()
Переведем все символы из текста в нижний регистр
# convert text to lowercase
columns = ['name_1','name_2']
for column in columns:
lower_str(baseline_train,column)
for column in columns:
lower_str(baseline_test,column)
Удалим названия стран
Надо отметить, что организаторы конкурса — большие молодцы! Вместе с заданием они дали ноутбук с очень простым baseline, в котором был предоставлен, в том числе и нижеприведенный код.
# drop any names of countries
countries = [country.name.lower() for country in pycountry.countries]
for country in tqdm(countries):
baseline_train.replace(re.compile(country), "", inplace=True)
baseline_test.replace(re.compile(country), "", inplace=True)
Удалим знаки и спецсимволы
# drop punctuation marks
baseline_train.replace(re.compile(r"\s+\(.*\)"), "", inplace=True)
baseline_test.replace(re.compile(r"\s+\(.*\)"), "", inplace=True)
baseline_train.replace(re.compile(r"[^\w\s]"), "", inplace=True)
baseline_test.replace(re.compile(r"[^\w\s]"), "", inplace=True)
Удалим цифры
Удаление цифр из текста прямо в лоб, в первой попытке сильно испортило качество модели. Код я здесь приведу, но по факту он не использовался.
Также обратите внимание, что до этого момента мы проводили преобразование прямо в столбцах, которые нам были даны. Давайте теперь будем создавать новые столбцы для каждой предобработки. Столбцов получится больше, зато если где-то на каком-то этапе предобработки произойдет сбой — ничего страшного, делать все с самого начала не нужно, ведь у нас будут столбы с каждого этапа предобработки.
# # first: make dictionary of frequency every word
# list_words = baseline_train['name_1'].to_string(index=False).split() +# baseline_train['name_2'].to_string(index=False).split()
# freq_words = {}
# for w in list_words:
# freq_words[w] = freq_words.get(w, 0) + 1
# # second: make data frame of frequency words
# df_freq = pd.DataFrame.from_dict(freq_words,orient='index').reset_index()
# df_freq.columns = ['word','frequency']
# df_freq_agg = df_freq.groupby(['word']).agg([sum]).reset_index()
# df_freq_agg = rename_agg_columns(id_client='word',data=df_freq_agg,rename='%s_%s')
# df_freq_agg = df_freq_agg.sort_values(by=['frequency_sum'], ascending=False)
# # third: make drop list of digits
# string = df_freq_agg['word'].to_string(index=False)
# digits = [int(digit) for digit in string.split() if digit.isdigit()]
# digits = set(digits)
# digits = list(digits)
# # drop the digits
# baseline_train['name_1_no_digits'] =# baseline_train['name_1'].apply(
# lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
# baseline_train['name_2_no_digits'] =# baseline_train['name_2'].apply(
# lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
# baseline_test['name_1_no_digits'] =# baseline_test['name_1'].apply(
# lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
# baseline_test['name_2_no_digits'] =# baseline_test['name_2'].apply(
# lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
Удалим… первый список стоп-слов. Вручную!
Теперь предлагается определить и удалить стоп-слова из списка слов в названиях компаний.
Список мы составили на основании ручного просмотра обучающей выборки. По логике, такой список нужно составлять автоматически, используя следующие подходы:
- во-первых, использовать топ 10 (20,50,100) часто встречающихся слов.
- во-вторых, использовать стандартные библиотеки стоп-слов на различных языках. Например, обозначения организационно-правовых форм организаций на различных языках (ООО, ПАО, ЗАО, ltd, gmbh, inc и др.)
- в-третьих, имеет смысл составить список географических названий на различных языках
К первому варианту автоматического составления списка топ часто встречающихся слов мы еще вернемся, а пока смотрим на ручную предобработку.
# drop some stop-words
drop_list = ["ltd.", "co.", "inc.", "b.v.", "s.c.r.l.", "gmbh", "pvt.",
'retail','usa','asia','ceska republika','limited','tradig','llc','group',
'international','plc','retail','tire','mills','chemical','korea','brasil',
'holding','vietnam','tyre','venezuela','polska','americas','industrial','taiwan',
'europe','america','north','czech republic','retailers','retails',
'mexicana','corporation','corp','ltd','co','toronto','nederland','shanghai','gmb','pacific',
'industries','industrias',
'inc', 'ltda', 'ооо', 'ООО', 'зао', 'ЗАО', 'оао', 'ОАО', 'пао', 'ПАО', 'ceska republika', 'ltda',
'sibur', 'enterprises', 'electronics', 'products', 'distribution', 'logistics', 'development',
'technologies', 'pvt', 'technologies', 'comercio', 'industria', 'trading', 'internacionais',
'bank', 'sports',
'express','east', 'west', 'south', 'north', 'factory', 'transportes', 'trade', 'banco',
'management', 'engineering', 'investments', 'enterprise', 'city', 'national', 'express', 'tech',
'auto', 'transporte', 'technology', 'and', 'central', 'american',
'logistica','global','exportacao', 'ceska republika', 'vancouver', 'deutschland',
'sro','rus','chemicals','private','distributors','tyres','industry','services','italia','beijing',
'рус','company','the','und']
baseline_train['name_1_non_stop_words'] = baseline_train['name_1'].apply(
lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
baseline_train['name_2_non_stop_words'] = baseline_train['name_2'].apply(
lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
baseline_test['name_1_non_stop_words'] = baseline_test['name_1'].apply(
lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
baseline_test['name_2_non_stop_words'] = baseline_test['name_2'].apply(
lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
Давайте выборочно проверим, что наши стоп слова были действительно удалены из текста
baseline_train[baseline_train.name_1_non_stop_words.str.contains("factory")].head(3)
Таблица №4 «Выборочная проверка работы кода по удалению стоп-слов»
Вроде все работает. Удалены все стоп-слова, которые отделены пробелом. То, что мы и хотели. Двигаемся дальше.
Проведем транслитерацию русского текста в латиницу
Я использую для этого свою самописную функцию и библиотеку cyrtranslit. Вроде работает. Проверял вручную.
# transliteration to latin
baseline_train['name_1_transliterated'] = transliterate(baseline_train['name_1_non_stop_words'])
baseline_train['name_2_transliterated'] = transliterate(baseline_train['name_2_non_stop_words'])
baseline_test['name_1_transliterated'] = transliterate(baseline_test['name_1_non_stop_words'])
baseline_test['name_2_transliterated'] = transliterate(baseline_test['name_2_non_stop_words'])
Давайте посмотрим на пару с id 353150. В ней второй столбец («name_2») имеет слово «мишлен», после предобработки слово уже пишется так «mishlen» (см. столбец «name_2_transliterated»). Не совсем правильно, но явно лучше.
pair_id = 353150
baseline_train[baseline_train['pair_id']==353150]
Таблица №5 «Выборочная проверка кода по транслитерации»
Запустим автоматическое составление списка топ 50 самых часто встречающихся слов & Drop it smart. Первый ЧИТ
Немного мудреный заголовок. Давайте по порядку разберем, что мы тут будем делать.
Во-первых, мы объединим текст из первого и второго столбца в один массив и посчитаем для каждого уникального слова, количество раз, которое оно встречалось.
Во-вторых, выберем топ 50 таких слов. И казалось бы можно их удалить, но нет. В этих словах могут быть названия холдингов ('total', 'knauf', 'shell',...), а это очень важная информация и ее нельзя потерять, так как далее мы будем ее использовать. Поэтому мы пойдем на читерский (запрещенный) прием. Для начала, на основании внимательного, выборочного изучения обучающей выборки, составим список названий часто встречающихся холдингов. Список будет не полный, иначе это было бы совсем не честно :) Хотя, так как мы не гонимся за призовым местом, то почему бы и нет. Затем мы сравним массив топ 50 часто встречающихся слов со списком названий холдингов и выкинем из списка слова, которые совпадают с названиями холдингов.
Теперь второй список стоп-слов готов. Можно удалять слова из текста.
Но перед тем, хотелось бы вставить небольшую ремарочку касательно читерского списка названий холдингов. То, что мы составили на основании наблюдений список из названий холдингов сильно упростило нам жизнь. Но на самом деле, мы могли составить такой список другим способом. Например, можно взять рейтинги крупнейших компаний в нефтехимической, строительной, автомобильной и других отраслях, объединить их и взять оттуда названия холдингов. Но в целях нашего исследования, мы ограничимся простым подходом. Такой подход запрещен в рамках соревнования! Более того, организаторы соревнований, работы кандидатов на призовые места проверяют на предмет запрещенных приемов. Будьте внимательны!
list_top_companies = ['arlanxeo', 'basf', 'bayer', 'bdp', 'bosch', 'brenntag', 'contitech',
'daewoo', 'dow', 'dupont', 'evonik', 'exxon', 'exxonmobil', 'freudenberg',
'goodyear', 'goter', 'henkel', 'hp', 'hyundai', 'isover', 'itochu', 'kia', 'knauf',
'kraton', 'kumho', 'lusocopla', 'michelin', 'paul bauder', 'pirelli', 'ravago',
'rehau', 'reliance', 'sabic', 'sanyo', 'shell', 'sherwinwilliams', 'sojitz',
'soprema', 'steico', 'strabag', 'sumitomo', 'synthomer', 'synthos',
'total', 'trelleborg', 'trinseo', 'yokohama']
# drop top 50 common words (NAME 1 & NAME 2) exept names of top companies
# first: make dictionary of frequency every word
list_words = baseline_train['name_1_transliterated'].to_string(index=False).split() + baseline_train['name_2_transliterated'].to_string(index=False).split()
freq_words = {}
for w in list_words:
freq_words[w] = freq_words.get(w, 0) + 1
# # second: make data frame
df_freq = pd.DataFrame.from_dict(freq_words,orient='index').reset_index()
df_freq.columns = ['word','frequency']
df_freq_agg = df_freq.groupby(['word']).agg([sum]).reset_index()
df_freq_agg = rename_agg_columns(id_client='word',data=df_freq_agg,rename='%s_%s')
df_freq_agg = df_freq_agg.sort_values(by=['frequency_sum'], ascending=False)
drop_list = list(set(df_freq_agg[0:50]['word'].to_string(index=False).split()) - set(list_top_companies))
# # check list of top 50 common words
# print (drop_list)
# drop the top 50 words
baseline_train['name_1_finish'] = baseline_train['name_1_transliterated'].apply(
lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
baseline_train['name_2_finish'] = baseline_train['name_2_transliterated'].apply(
lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
baseline_test['name_1_finish'] = baseline_test['name_1_transliterated'].apply(
lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
baseline_test['name_2_finish'] = baseline_test['name_2_transliterated'].apply(
lambda x: ' '.join([word for word in x.split() if word not in (drop_list)]))
На этом мы закончили с предобработкой данных. Начнем генерировать новые фичи и визуально их оценивать на способность разделять объекты на 0 или 1.
Генерация и анализ фич
Посчитаем дистанцию Левенштейна
Воспользуемся библиотекой strsimpy и в каждой паре (после всех предобработок) посчитаем дистанцию Левенштейна от названия компании из первого столбца до названия компании во втором столбце.
# create feature with LEVENSTAIN DISTANCE
levenshtein = Levenshtein()
column_1 = 'name_1_finish'
column_2 = 'name_2_finish'
baseline_train["levenstein"] = baseline_train.progress_apply(
lambda r: levenshtein.distance(r[column_1], r[column_2]), axis=1)
baseline_test["levenstein"] = baseline_test.progress_apply(
lambda r: levenshtein.distance(r[column_1], r[column_2]), axis=1)
Посчитаем нормализованную дистанцию Левенштейна
Все то же самое, что и выше, только считать мы будем нормированную дистанцию.
# create feature with NORMALIZATION LEVENSTAIN DISTANCE
normalized_levenshtein = NormalizedLevenshtein()
column_1 = 'name_1_finish'
column_2 = 'name_2_finish'
baseline_train["norm_levenstein"] = baseline_train.progress_apply(
lambda r: normalized_levenshtein.distance(r[column_1], r[column_2]),axis=1)
baseline_test["norm_levenstein"] = baseline_test.progress_apply(
lambda r: normalized_levenshtein.distance(r[column_1], r[column_2]),axis=1)
Посчитали, а теперь визуализируем
Визуализируем фичи
Посмотрим на распределение признака 'levenstein'
data = baseline_train
analyse = 'levenstein'
size = (12,2)
dd = data_statistics(data,analyse,title_print='no')
hist_fz(data,dd,analyse,size)
boxplot(data,analyse,size)
Графики №1 «Гистограмма и ящик с усами для оценки значимости признака»
На первый взгляд, метрика может размечать данные. Очевидно не очень хорошо, но использовать ее можно.
Посмотрим на распределение признака 'norm_levenstein'
data = baseline_train
analyse = 'norm_levenstein'
size = (14,2)
dd = data_statistics(data,analyse,title_print='no')
hist_fz(data,dd,analyse,size)
boxplot(data,analyse,size)
Графики №2 «Гистограмма и ящик с усами для оценки значимости признака»
Уже лучше. А теперь, давайте посмотрим на то, как две вместе взятые фичи будут разделять пространство на объекты 0 и 1.
data = baseline_train
analyse1 = 'levenstein'
analyse2 = 'norm_levenstein'
size = (14,6)
two_features(data,analyse1,analyse2,size)
График №3 «Диаграмма рассеяния»
Очень даже неплохая разметка получается. Значит не зря мы столько предобрабатывали данные :)
Всем же понятно, что по горизонтали — значения метрики «levenstein», а по вертикали — значения метрики «norm_levenstein», а зелененькие и черненькие точки это объекты 0 и 1. Двигаемся дальше.
Сопоставим слова в тексте для каждой пары и сгенерим большую кучу признаков
Ниже мы проведем сравнение слов в названиях компаний. Создадим следующие признаки:
- список слов, которые дублируются в столбцах №1 и №2 каждой пары
- список слов, которые НЕ дублируются
На основании этих списков слов, создадим признаки, которые мы подадим в обучаемую модель:
- количество дублирующихся слов
- количество НЕ дублирующихся слов
- cумма символов, дублирующихся слов
- сумма символов, НЕ дублирующихся слов
- средняя длина дублирующихся слов
- средняя длина НЕ дублирующихся слов
- отношение количества дубликатов к количеству НЕ дубликатов
Код здесь, наверное, не очень дружелюбный, так как опять-таки, написан был на скорую руку. Но он работает, а для быстрого исследования пойдет.
# make some information about duplicates and differences for TRAIN
column_1 = 'name_1_finish'
column_2 = 'name_2_finish'
duplicates = []
difference = []
for i in range(baseline_train.shape[0]):
list1 = list(baseline_train[i:i+1][column_1])
str1 = ''.join(list1).split()
list2 = list(baseline_train[i:i+1][column_2])
str2 = ''.join(list2).split()
duplicates.append(list(set(str1) & set(str2)))
difference.append(list(set(str1).symmetric_difference(set(str2))))
# continue make information about duplicates
duplicate_count,duplicate_sum = compair_metrics(duplicates)
dif_count,dif_sum = compair_metrics(difference)
# create features have information about duplicates and differences for TRAIN
baseline_train['duplicate'] = duplicates
baseline_train['difference'] = difference
baseline_train['duplicate_count'] = duplicate_count
baseline_train['duplicate_sum'] = duplicate_sum
baseline_train['duplicate_mean'] = baseline_train['duplicate_sum'] / baseline_train['duplicate_count']
baseline_train['duplicate_mean'] = baseline_train['duplicate_mean'].fillna(0)
baseline_train['dif_count'] = dif_count
baseline_train['dif_sum'] = dif_sum
baseline_train['dif_mean'] = baseline_train['dif_sum'] / baseline_train['dif_count']
baseline_train['dif_mean'] = baseline_train['dif_mean'].fillna(0)
baseline_train['ratio_duplicate/dif_count'] = baseline_train['duplicate_count'] / baseline_train['dif_count']
# make some information about duplicates and differences for TEST
column_1 = 'name_1_finish'
column_2 = 'name_2_finish'
duplicates = []
difference = []
for i in range(baseline_test.shape[0]):
list1 = list(baseline_test[i:i+1][column_1])
str1 = ''.join(list1).split()
list2 = list(baseline_test[i:i+1][column_2])
str2 = ''.join(list2).split()
duplicates.append(list(set(str1) & set(str2)))
difference.append(list(set(str1).symmetric_difference(set(str2))))
# continue make information about duplicates
duplicate_count,duplicate_sum = compair_metrics(duplicates)
dif_count,dif_sum = compair_metrics(difference)
# create features have information about duplicates and differences for TEST
baseline_test['duplicate'] = duplicates
baseline_test['difference'] = difference
baseline_test['duplicate_count'] = duplicate_count
baseline_test['duplicate_sum'] = duplicate_sum
baseline_test['duplicate_mean'] = baseline_test['duplicate_sum'] / baseline_test['duplicate_count']
baseline_test['duplicate_mean'] = baseline_test['duplicate_mean'].fillna(0)
baseline_test['dif_count'] = dif_count
baseline_test['dif_sum'] = dif_sum
baseline_test['dif_mean'] = baseline_test['dif_sum'] / baseline_test['dif_count']
baseline_test['dif_mean'] = baseline_test['dif_mean'].fillna(0)
baseline_test['ratio_duplicate/dif_count'] = baseline_test['duplicate_count'] / baseline_test['dif_count']
Визуализируем некоторые признаки.
data = baseline_train
analyse = 'dif_sum'
size = (14,2)
dd = data_statistics(data,analyse,title_print='no')
hist_fz(data,dd,analyse,size)
boxplot(data,analyse,size)
Графики №4 «Гистограмма и ящик с усами для оценки значимости признака»
data = baseline_train
analyse1 = 'duplicate_mean'
analyse2 = 'dif_mean'
size = (14,6)
two_features(data,analyse1,analyse2,size)
График №5 «Диаграмма рассеяния»
Какая никакая, а разметка. Обратим внимание на то, что очень много компаний с целевой меткой 1 имеют ноль дублей в тексте и также очень много компаний, имеющих дубли в названиях в среднем более 12 слов относятся к компаниям с целевой меткой 0.
Давайте взглянем на табличные данные, подготовим запрос по первому случаю: дублей в названии компаний ноль, а компании одинаковые.
baseline_train[
baseline_train['duplicate_mean']==0][
baseline_train['target']==1].drop(
['duplicate', 'difference',
'name_1_non_stop_words',
'name_2_non_stop_words', 'name_1_transliterated',
'name_2_transliterated'],axis=1)
Очевидно, есть системная ошибка в нашей обработке. Мы не учли, что слова могут писаться не только с ошибками, но и просто слитно или наоборот раздельно там, где этого не требуется. Например, пара №9764. В первом столбце 'bitmat' во втором 'bit mat' и вот уже это не дубль, а компания та одинаковая. Или другой пример, пара №482600 'bridgestoneshenyang' и 'bridgestone'.
Что можно было бы сделать. Первое, что мне пришло в голову — сопоставлять не напрямую в лоб, а с помощью метрики Левенштейна. Но и здесь нас подстерегает засада: расстояние между 'bridgestoneshenyang' и 'bridgestone' будет не маленьким. Возможно на помощь придет лемматизация, но опять-таки сходу не ясно как можно лемматизировать названия компаний. Или можно использовать коэффициент Тамимото, но оставим этот момент для более опытных товарищей и двигаемся дальше.
Сопоставим слова из текста со словами из названий топ 50 холдинговых брендов нефтехимической, строительной и других отраслей. Получим вторую большую кучу признаков. Второй ЧИТ
На самом деле здесь целых два нарушения правил участия соревнования:
- во-первых, мы сопоставим прямо в лоб, выявленные раннее дубликаты слов из каждой пары на соответствие названий холдингов и получим столбец «duplicate_name_company»
- во-вторых, мы проведем более хитрое сопоставление. Будем сверять данные не напрямую, а через дистанцию Левенштейна.
Оба приема запрещены правилами соревнований. Обойти запрет можно. Для этого нужно составить список названий холдингов не вручную на основании выборочного просмотра обучающей выборки, а автоматически — из внешних источников. Но тогда, во-первых, список холдингов получится большим и предложенное в работе сопоставление слов займет очень, ну просто очень много времени, а во-вторых, этот список еще нужно составить :) Поэтому для целей простоты исследования проведем проверку насколько улучшиться качество модели с этими признаками. Забегу вперед — качество растет просто потрясающе!
С первым способом вроде все должно быть понятно, а вот ко второму подходу требуются пояснения.
Итак, определим дистанцию Левенштейна от каждого слова в каждой строчке первого столбца с названием компании до каждого слова из списка топ нефтехимических компаний (и не только).
В случае, если отношение дистанции Левенштейна к длине слова менее или равно 0.4, то мы определяем отношение дистанции Левенштейна до выбранного слова из списка топ компаний к каждому слову из второго столбца — названия второй компании.
Если и второй коэффициент (отношение дистанции к длине слова из списка топ компаний) оказывается ниже или равен 0.4, то мы фиксируем следующие значения в таблицу:
- дистанция Левенштейна от слова из списка №1 компаний до слова в списке топ компаний
- дистанция Левенштейна от слова из списка №2 компаний до слова в списке топ компаний
- длина слова из списка №1
- длина слова из списка №2
- длина слова из списка топ компаний
- отношение длина слова из списка №1 к дистанции
- отношение длина слова из списка №2 к дистанции
В одной строке может быть более одного совпадения, выберем минимальное из них (функция агрегации).
Хотелось бы еще раз обратить внимание, на то, что предложенный способ генерации фич, достаточно ресурсоемкий и в случае получения списка из внешнего источника потребуется изменение в коде по составлению метрик.
# create information about duplicate name of petrochemical companies from top list
list_top_companies = list_top_companies
dp_train = []
for i in list(baseline_train['duplicate']):
dp_train.append(''.join(list(set(i) & set(list_top_companies))))
dp_test = []
for i in list(baseline_test['duplicate']):
dp_test.append(''.join(list(set(i) & set(list_top_companies))))
baseline_train['duplicate_name_company'] = dp_train
baseline_test['duplicate_name_company'] = dp_test
# replace name duplicate to number
baseline_train['duplicate_name_company'] = baseline_train['duplicate_name_company'].replace('',0,regex=True)
baseline_train.loc[baseline_train['duplicate_name_company'] != 0, 'duplicate_name_company'] = 1
baseline_test['duplicate_name_company'] = baseline_test['duplicate_name_company'].replace('',0,regex=True)
baseline_test.loc[baseline_test['duplicate_name_company'] != 0, 'duplicate_name_company'] = 1
# create some important feature about similar words in the data and names of top companies for TRAIN
# (levenstein distance, length of word, ratio distance to length)
baseline_train = dist_name_to_top_list_make(baseline_train,
'name_1_finish','name_2_finish',list_top_companies)
# create some important feature about similar words in the data and names of top companies for TEST
# (levenstein distance, length of word, ratio distance to length)
baseline_test = dist_name_to_top_list_make(baseline_test,
'name_1_finish','name_2_finish',list_top_companies)
Посмотрим на полезность признаков сквозь призму графиков
data = baseline_train
analyse = 'levenstein_dist_w1_top_w_min'
size = (14,2)
dd = data_statistics(data,analyse,title_print='no')
hist_fz(data,dd,analyse,size)
boxplot(data,analyse,size)
Очень хорошо.
Готовим данные для подачи в модель
У нас получилась большая таблица и далеко не все данные для анализа нам нужны. Посмотрим на названия столбцов таблицы.
baseline_train.columns
Выберем те столбцы, которые будем анализировать.
Зафиксируем seed для воспроизводимости результата.
# fix some parameters
features = ['levenstein','norm_levenstein',
'duplicate_count','duplicate_sum','duplicate_mean',
'dif_count','dif_sum','dif_mean','ratio_duplicate/dif_count',
'duplicate_name_company',
'levenstein_dist_w1_top_w_min', 'levenstein_dist_w2_top_w_min',
'length_w1_top_w_min', 'length_w2_top_w_min', 'length_top_w_min',
'ratio_dist_w1_to_top_w_min', 'ratio_dist_w2_to_top_w_min'
]
seed = 42
Перед тем, как окончательно обучить модель на всех доступных данных и отправить решение на проверку имеет смысл протестировать модель. Для этого разобьем обучающую выборку на условно обучающую и условно тестовую. Померим на ней качество и если нас оно устраивает, то будем отправлять решение на конкурс.
# provides train/test indices to split data in train/test sets
split = StratifiedShuffleSplit(n_splits=1, train_size=0.8, random_state=seed)
tridx, cvidx = list(split.split(baseline_train[features],
baseline_train["target"]))[0]
print ('Split baseline data train',baseline_train.shape[0])
print (' - new train data:',tridx.shape[0])
print (' - new test data:',cvidx.shape[0])
Настройка и обучение модели
В качестве модели будем использовать решающее дерево из библиотеки Light GBM.
Сильно накручивать параметры не имеет смысла. Смотрим код.
# learning Light GBM Classificier
seed = 50
params = {'n_estimators': 1,
'objective': 'binary',
'max_depth': 40,
'min_child_samples': 5,
'learning_rate': 1,
# 'reg_lambda': 0.75,
# 'subsample': 0.75,
# 'colsample_bytree': 0.4,
# 'min_split_gain': 0.02,
# 'min_child_weight': 40,
'random_state': seed}
model = lgb.LGBMClassifier(**params)
model.fit(baseline_train.iloc[tridx][features].values,
baseline_train.iloc[tridx]["target"].values)
Модель настроили и обучили. Теперь давайте посмотрим на результаты.
# make predict proba and predict target
probability_level = 0.99
X = baseline_train
tridx = tridx
cvidx = cvidx
model = model
X_tr, X_cv = contingency_table(X,features,probability_level,tridx,cvidx,model)
train_matrix_confusion = matrix_confusion(X_tr)
cv_matrix_confusion = matrix_confusion(X_cv)
report_score(train_matrix_confusion,
cv_matrix_confusion,
baseline_train,
tridx,cvidx,
X_tr,X_cv)
Обратите внимание на то, что мы в качестве оценки модели используем метрику качества f1. Значит, имеет смысл регулировать уровень вероятности отнесения объекта к классу 1 или 0. Мы выбрали уровень 0.99, то есть при вероятности равной и выше 0.99 объект будет отнесен к классу 1, ниже 0.99 — к классу 0. Это важный момент — можно существенно поправить скор таким не хитрым простым трюком.
Качество вроде бы не плохое. На условно тестовой выборке алгоритм допустил ошибки при определении 222 объектов класса 0 и на 90 объектах, относящихся к классу 0 ошибся и отнес их к классу 1 (см.Matrix confusion on test(cv) data).
Давайте посмотрим какие признаки были наиболее важными, а какие нет.
start = 0
stop = 50
size = (12,6)
tg = table_gain_coef(model,features,start,stop)
gain_hist(tg,size,start,stop)
display(tg)
Заметим, что для оценки значимости признаков мы использовали параметр 'gain', а не 'split'. Это важно так как в очень упрощенном варианте первый параметр означает вклад признака в уменьшении энтропии, а второй указывает на то, сколько раз признак использовался для разметки пространства.
На первый взгляд, признак, который мы делали очень долго «levenstein_dist_w1_top_w_min» оказался совсем не информативным — его вклад равен 0. Но это только на первый взгляд. Он просто почти полностью дублируется по смыслу с признаком «duplicate_name_company». Если удалить «duplicate_name_company» и оставить «levenstein_dist_w1_top_w_min», то второй признак займет место первого и качество не поменяется. Проверено!
Вообще такая табличка — удобная штука особенно когда у тебя сотни признаков и модель с кучей прибамбасов и итераций 5000. Можно пачками удалять признаки и смотреть как растет от этого не хитрого действия качество. В нашем случае, удаление признаков не отразится на качестве.
Давайте посмотрим на таблицу сопряжения. В первую очередь посмотрим на объекты «False Positive», то есть те, которые наш алгоритм определил как одинаковые и отнес их к классу 1, а на самом деле они относятся к классу 0.
X_cv[X_cv['False_Positive']==1][0:50].drop(['name_1_non_stop_words',
'name_2_non_stop_words', 'name_1_transliterated',
'name_2_transliterated', 'duplicate', 'difference',
'levenstein',
'levenstein_dist_w1_top_w_min', 'levenstein_dist_w2_top_w_min',
'length_w1_top_w_min', 'length_w2_top_w_min', 'length_top_w_min',
'ratio_dist_w1_to_top_w_min', 'ratio_dist_w2_to_top_w_min',
'True_Positive','True_Negative','False_Negative'],axis=1)
Да уж. Здесь и человек то сходу не определит 0 или 1. Например, пара №146825 «mitsubishi corp» и «mitsubishi corp l». Глаза говорят что это одно и тоже, а выборка говорит, что разные компании. Кому верить?
Скажем так, что сходу можно было выжать — мы выжали. Остальную работу оставим опытным товарищам :)
Давайте загрузим данные на сайт организатора и узнаем оценку качества работы.
Итоги соревнования
model = lgb.LGBMClassifier(**params)
model.fit(baseline_train[features].values,
baseline_train["target"].values)
sample_sub = pd.read_csv('sample_submission.csv', index_col="pair_id")
sample_sub['is_duplicate'] = (model.predict_proba(
baseline_test[features].values)[:, 1] > probability_level).astype(np.int)
sample_sub.to_csv('baseline_submission.csv')
Итак, наш скор с учетом запрещенного приема: 0.5999
Без него, качество было где-то между 0.3 и 0.4. Надо перезапускать модель для точности, а мне немного лень :)
Давайте лучше резюмируем полученный опыт.
Во-первых, как можно видеть, у нас получился вполне себе воспроизводимый код и достаточно адекватная структура файла. Из-за малого опыта я в свое время набил очень много шишек именно из-за того, что оформлял работы наспех, лишь бы получить какой-либо более-менее приятный скор. В итоге файл получался таким, что через неделю его уже было страшно открывать — настолько ничего не понятно. Поэтому, мой посыл — пишите сразу код и делайте файл читаемым, так чтобы через год можно было вернуться к данным, посмотреть для начала на структуру, понять какие шаги были предприняты и потом, чтобы каждый шаг по отдельности можно было легко разобрать. Конечно, если Вы новичок, то с первой попытки файл не будет красивым, код будет ломаться, будут костыли, но если периодически, в процессе исследования, переписывать код заново, то на 5-7 раз переписывания Вы сами удивитесь тому, насколько код стал чище и возможно даже найдете ошибки и улучшите скор. Не забывайте про функции, очень облегчает читаемость файла.
Во-вторых, после каждой обработки данных, проверяйте, все ли прошло как задумано. Для этого надо уметь фильтровать таблицы в pandas. В этой работе много фильтрации, пользуйтесь на здоровье :)
В-третьих, всегда, прямо-таки всегда, в задачах классификации, формируйте и таблицу и матрицу сопряжения. По таблице Вы легко найдете на каких объектах ошибается алгоритм. Для начала старайтесь подмечать те ошибки, которые что называются системные, они требуют меньше работы по исправлению, а дают больший результат. Потом уже как разберете системные ошибки, переходите на частные случаи. По матрице ошибок Вы увидите где больше ошибается алгоритм: на классе 0 или 1. Отсюда Вы и будете копать ошибки. Например, я заметил, что мое дерево хорошо определяет классы 1, но допускает много ошибок на классе 0, то есть дерево часто «говорит», что этот объект класса 1, тогда как на самом деле он 0. Я предположил, что это может быть связано с уровнем вероятности отнесения объекта к классу 0 или 1. У меня уровень был зафиксирован на 0.9. Увеличение уровня вероятности отнесения объекта к классу 1 до 0.99, сделало отбор объектов класса 1 жестче и вуаля — наш скор дал существенный прирост.
Еще раз отмечу, что целью участия в соревновании у меня было не занятие призового места, а получение опыта. Учитывая, что до начала соревнования я понятия не имел как работать с текстами в машинном обучении, а в итоге за несколько дней получилась простая, но все же рабочая модель, то можно сказать цель достигнута. Также и для любого начинающего самурая в мире data science я считаю важно получение опыта, а не приз, вернее опыт — это и есть приз. Поэтому не бойтесь участия в соревнованиях, дерзайте, всем бобра!
На момент публикации статьи, соревнование еще не закончено. По результатам завершения соревнования в комментарии к статье я напишу о максимальном честном скоре, о подходах и фичах, которые улучшают качество модели.
А Вы — уважаемый читатель, если располагаете идеями о том, как увеличить скор уже сейчас, пишите в комментах. Сделаете доброе дело :)
Источники информации, вспомогательные материалы
- «Гитхаб с данными и Jupyter Notebook»
- «Платформа соревнования SIBUR CHALLENGE 2020»
- «Сайт организатора соревнования SIBUR CHALLENGE 2020»
- «Хорошая статья на Хабре „Основы Natural Language Processing для текста“»
- «Еще одна хорошая статья на Хабре „Нечёткое сравнение строк: пойми меня, если сможешь“»
- «Публикация из журнала АПНИ»
- «Статья о не использованном здесь коэффициенте Танимото „Степень схожести строк“»
AlexanderPetrenko Автор
Не успело пройти и 10 минут, а знатоки уже нашли ошибку :)
Мы сначала перевели текст в нижний регистр, а потом начали удалять слова типа ПАО ЗАО…
По результатам всех замечаний и иных инициатив, по завершению соревнования, на гитхаб выложу поправленное решение. Посмотрим какой будет скор.