<jupyter_start><jupyter_text>Открытый курс по машинному обучению. Сессия № 3  Автор материала: <NAME> (<EMAIL>)   Индивидуальный проект по анализу данных   "Прогнозирование бронирований на AirBnB"  **План исследования** - Описание набора данных и признаков - Первичный анализ признаков - Первичный визуальный анализ признаков - Закономерности, "инсайты", особенности данных - Предобработка данных - Создание новых признаков и описание этого процесса - Кросс-валидация, подбор параметров - Построение кривых валидации и обучения  - Прогноз для тестовой или отложенной выборки - Оценка модели с описанием выбранной метрики - Выводы  Более детальное описание [тут](https://goo.gl/cJbw7V).  Импортируем необходимые библиотеки<jupyter_code>import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
import seaborn as sns
from sklearn.preprocessing import LabelBinarizer, StandardScaler, LabelEncoder, OneHotEncoder
from sklearn.linear_model import LogisticRegression, LogisticRegressionCV
from sklearn.model_selection import train_test_split, GridSearchCV, validation_curve
from sklearn.metrics import make_scorer
from scipy.sparse import csr_matrix, hstack, vstack
import xgboost as xgb
from xgboost import XGBClassifier
import pickle
from sklearn.model_selection import learning_curve

%matplotlib inline
# figure size in inches
import warnings
warnings.filterwarnings(module='sklearn*', action='ignore', category=DeprecationWarning)
plt.rcParams['figure.figsize'] = 16,9
def create_submission(predict, id_vals, enc, filename):
    # Для каждого ID пользователя отбираем 5 классов с максимальной предсказанной вероятностью
    ids = []  # спикок ID пользователей
    cts = []  # список стран
    for i in range(len(id_vals)):
        idx = id_vals[i]
        ids += [idx] * 5
        cts += enc.inverse_transform(np.argsort(predict[i])[::-1])[:5].tolist()
    #Generate submission
    sub = pd.DataFrame(np.column_stack((ids, cts)), columns=['id', 'country'])
    sub.to_csv(filename, index=False) 
# Реализация вычисления целевой метрики NDCG
def my_ndcg(y_true, y_predict, k=5):
    ndcg = 0
    n = 0
    for y_true_val, y_predict_vec in zip(y_true, y_predict):
        #print(y_true_val, y_predict_vec)
        order = np.argsort(y_predict_vec)[::-1][:k]
        #print(order)
        for idx, y_predict_val in enumerate(order):
            if y_true_val == y_predict_val:
                #print(1 / np.log2(idx + 2))
                ndcg += 1 / np.log2(idx + 2)
                break
        n += 1
    #print(ndcg)
    return ndcg / n
ndcg_scorer = make_scorer(my_ndcg, needs_proba=True, k=5)<jupyter_output><empty_output><jupyter_text>Часть 1. Описание набора данных и признаков  Введение AirBnB - это онлайн-площадка для размещения, поиска и краткосрочной аренды частного жилья по всему миру. Пользователи Airbnb имеют возможность сдавать путешественникам в аренду своё жильё целиком или частично. Сайт предоставляет платформу для установления контакта между хозяином и гостем, а также отвечает за обработку транзакций. Данный индивидуальный проект посвящен задаче прогнозирования страны первого бронирования жилья для новых пользователей AirBnb (*задача многоклассовой классификации*). Ценность этой задачи для бизнеса состоит в том, что правильное прогнозирование места первого бронирования позволит делать пользователям персонализированные предложения (баннеры на сайте, рассылка по почте). Это в свою очередь должно снизить среднее время от регистрации нового пользователя до первого бронирования, что повысит конверсию пользователей и благотворно скажется на финансовом результате компании.  Описание набора данных и признаков Набор данных получен из Kaggle ([Airbnb New User Bookings](https://www.kaggle.com/c/airbnb-recruiting-new-user-bookings/data)) и включает несколько файлов с данными о пользователях (вкл. социально-демографические данные) и пользовательских веб-сессиях:* *train_users.csv* - обучающая выборка данных о пользователях.|               Переменная | Описание ||--------------------------|----------||                       id | ID пользователя ||     date_account_created | Дата создания аккаунта на airbnb ||   timestamp_first_active | Дата и время первой активности на сайте airbnb.com ||       date_first_booking | Дата первого бронирования ||                   gender | Пол ||                      age | Возраст ||            signup_method | Способ регистрации (базовый, профиль facebook или google) ||              signup_flow | Страница, с которой пользователь перешел для регистрации ||                 language | Предпочитаемый язык ||        affiliate_channel | Тип рекламы ||       affiliate_provider | Провайдер рекламы ||  first_affiliate_tracked | Первый тип рекламы, с которым взаимодействовал пользователь перед регистрацией ||               signup_app | Приложение, с которого была регистрация ||        first_device_type | Тип первого девайса пользователя ||            first_browser | Первый браузер пользователя ||  **country_destination** | **Целевая переменная** - страна первого бронирования |* *test_users.csv* - тестовая выборка данных о пользователях. Структура аналогична train_users.csv за исключением отсутсвия целевой переменной country_destination.* *sessions.csv* - данные о web-сессиях пользователей (клики, обновление wish-листов, поиск и др.). Будет использоваться для обогащения основных признаков по пользоватлям.|               Переменная |          Описание ||--------------------------|-------------------||                       id |   ID пользователя ||                   action | Действие на сайте ||              action_type | Тип действия ||            action_detail | Детализация действия ||            device_type | Тип девайса ||            secs_elapsed | Длительность действия |  Общие замечания по исходным данным * Все пользователи в предоставленных данных из США.* Имеются 12 возможных значений целевой переменной - страны первого бронирования: US (США), FR (Франция), CA (Канада), GB (Великобритания), ES (Испания), IT (Италия), PT (Португалия), NL (Нидерланды), DE (Германия), AU (Австралия), NDF (no destination found), and 'other' (другое).* 'other' (другое) - означает, что пользователь сделал бронирование, но страна бронирования не входит в приведенный выше список. А 'NDF' означает, что первого бронирования не было.* Тестовые и обучающие наборы данных не пересекаются по датам. В тестовом наборе данных нужно спрогнозироать страну первого бронирования для пользователей, у которых первые активности на airbnb (регистрация, поиск, клики) состоялись после 01.07.2014.   Часть 2. Первичный анализ признаков  Загружаем исходные данные<jupyter_code>df_train = pd.read_csv('../data/train_users_2.csv')
df_test = pd.read_csv('../data/test_users.csv')
df_train.shape, df_test.shape<jupyter_output><empty_output><jupyter_text>Итак, у нас имеются данные по *213451* пользователям в обучающей выборке и *62096* пользователям в тестовой.  Первичный осмотр данных<jupyter_code>df_train.head()
df_test.head()<jupyter_output><empty_output><jupyter_text>Выводы по первичному осмотру данных:* По крайней мере в двух признаках (date_first_booking и age) есть пропуски. Далее мы исследуем этот вопрос подробнее.* В наборе данных очень много категориальных признаков. Соответственно, далее их нужно будет преобразовывать.* Признак timestamp_first_active нужно преобразовать к нормальному типу timestamp.<jupyter_code># Создадим объединенный набор данных - будет полезно для некоторых дальнейших исследований
df_all = pd.concat([df_train, df_test], axis=0, ignore_index=True)
# Удалим из объединенного датасета ID пользователя - он пока не нужен
df_all.drop('id', axis=1, inplace=True)
df_all.info()<jupyter_output><empty_output><jupyter_text>Профилирование категориальных переменных В нашем наборе данных много категориальных переменных. Давайте исследуем распределение их значений.<jupyter_code>categorical_features = [
    'affiliate_channel',
    'affiliate_provider',
    'country_destination',
    'first_affiliate_tracked',
    'first_browser',
    'first_device_type',
    'gender',
    'language',
    'signup_app',
    'signup_method'
]

for categorical_feature in categorical_features:
    df_all[categorical_feature] = df_all[categorical_feature].astype('category')
for cat_feat in categorical_features:
    heading = "Profile for {}".format(cat_feat)
    print("*" * len(heading))
    print(heading)
    print("*" * len(heading))
    print(df_all[cat_feat].value_counts())
    print("\n")<jupyter_output><empty_output><jupyter_text>Замечания по результатам профилирования категориальных признаков:* В некоторых признаках встречаются значения -unknown- - это очевидно пропущенные значению. Нужно принять это во внимание при очистке данных.* Распределение целевой переменной говорит о том, что подавляющее большинство пользователей (по факту 90%) попадает в две категории - они либо еще не сделали бронирование, либо сделали первое бронирование в США. На это стоит обратить внимание при моделировании, т.к. сильно обобщенная модель может выдавать только два прогноза - NDF и USA.  Переменная возраст (Age)<jupyter_code>df_all.age.describe()
df_all.age.min()
df_all.age.max()
df_all.age.value_counts()<jupyter_output><empty_output><jupyter_text>Мы видим, что в переменной age встречаются странные значения - больше 120 и меньше 10.<jupyter_code>sum(df_all.age > 120)
sum(df_all.age < 10)<jupyter_output><empty_output><jupyter_text>На этапе предобработки такие данные потребуют особого внимания.  Пропущенные значения Для более объективной картины по пропущенным значениям заменим значения '-unknown-' в некоторых категориальных признаках на стандартное неопределенное значение np.nan.<jupyter_code>df_all.gender.replace('-unknown-', np.nan, inplace=True)
df_train.gender.replace('-unknown-', np.nan, inplace=True)
df_test.gender.replace('-unknown-', np.nan, inplace=True)

df_all.language.replace('-unknown-', np.nan, inplace=True)
df_train.language.replace('-unknown-', np.nan, inplace=True)
df_test.language.replace('-unknown-', np.nan, inplace=True)

df_all.first_browser.replace('-unknown-', np.nan, inplace=True)
df_train.first_browser.replace('-unknown-', np.nan, inplace=True)
df_test.first_browser.replace('-unknown-', np.nan, inplace=True)<jupyter_output><empty_output><jupyter_text>Проведем исследование пропущенных значений отдельно для обучающей и тестовой выборки.<jupyter_code>df_train_missing = df_train.isnull().sum() / df_train.shape[0]
df_train_missing[df_train_missing > 0]
df_test_missing = df_test.isnull().sum() / df_test.shape[0]
df_test_missing[df_test_missing > 0].drop('date_first_booking')<jupyter_output><empty_output><jupyter_text>В наших данных много пропусков в переменных gender, age, date_first_booking и first_browser. Переменная date_first_booking связана с целевой переменной и ее пропуски обусловлены значением NDF целевой переменной. По остальным переменным с пропусками нужно будет выбрать стратегии обработки.   Часть 3. Первичный визуальный анализ признаков  Распределение целевой переменной<jupyter_code>fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(16, 4))

country_counts = df_train.country_destination.value_counts()
country_counts.plot(kind='bar',title='Country Destination Count', ax=axes[0])

ax = country_counts.div(country_counts.sum()).plot(kind='bar',title='Country Destination %',ax=axes[1] )
ax.set_yticklabels(['{:3.1f}%'.format(x*100) for x in ax.get_yticks()])

booked_count = df_train[df_train.country_destination != 'NDF'].country_destination.value_counts()
ax1 = booked_count.div(booked_count.sum()).plot(kind='bar',title='Country Destination % без NDF',ax=axes[2] )
ax1.set_yticklabels(['{:3.1f}%'.format(x*100) for x in ax1.get_yticks()]);<jupyter_output><empty_output><jupyter_text>Графики подтверждают наши наблюдения, сделанные ранее - большая часть значений целевой переменной относятся к двум классам - NDF (не сделавшие бронирование) и USA.  Распределение целевой переменной с учетом пола<jupyter_code>women = sum(df_train['gender'] == 'FEMALE')
men = sum(df_train['gender'] == 'MALE')

female_destinations = df_train[df_train['gender'] == 'FEMALE'].country_destination.value_counts() / women * 100
male_destinations = df_train[df_train['gender'] == 'MALE'].country_destination.value_counts() / men * 100

# Bar width
width = 0.4

male_destinations.plot(kind='bar', width=width, color='green', position=0, label='Male', rot=0, figsize=(16, 9))
female_destinations.plot(kind='bar', width=width, color='pink', position=1, label='Female', rot=0, figsize=(16, 9))

plt.legend()
plt.xlabel('Destination Country')
plt.ylabel('Percentage')

sns.despine()
plt.show()<jupyter_output><empty_output><jupyter_text>Судя по графику, практически нет различий в предпочтениях мужчин и женщин.  Распределение целевой переменной с учетом возраста<jupyter_code>sns.distplot(df_train[(df_train.age < 100)&(df_train.age > 10)].age.dropna(), color='#FD5C64')
plt.title("Распределение пользователей по возрасту")
#plt.figsize = (16, 9)
plt.xlabel('Age')
plt.rcParams['figure.figsize'] = 16,9<jupyter_output><empty_output><jupyter_text>AirBnb наиболее популярен среди пользователей от 25 до 40 лет.<jupyter_code>age_data = df_train[(df_train.age < 100)&(df_train.age > 10)]
sns.boxplot(x='country_destination', y='age', data=age_data, palette="muted");<jupyter_output><empty_output><jupyter_text>На этом графике не видно больших различий бронирования в зависимости от возраста пользователя. Попробуем посмотреть на распределение целевой переменной по возрасту на двух частях выборки, разделенных медианой.<jupyter_code>age_data.age.median()
age = age_data.age.median()

younger = sum(age_data['age'] < age)
older = sum(age_data['age'] > age)

younger_destinations = age_data[age_data['age'] < age].country_destination.value_counts() / younger * 100
older_destinations = age_data[age_data['age'] > age].country_destination.value_counts() / older * 100

younger_destinations.plot(kind='bar', width=width, color='#A1EA15', position=0, label='Younger 50%', rot=0)
older_destinations.plot(kind='bar', width=width, color='#4EE3C9', position=1, label='Older 50%', rot=0)

plt.legend()
plt.xlabel('Destination Country')
plt.ylabel('Percentage')

sns.despine()
plt.show()<jupyter_output><empty_output><jupyter_text>На этом графике видно, что "более молодая" половина делает чаще первое бронирование после регистрации (меньше доля NDF) и в то же время чаще делает бронирование в США.  Временные признаки<jupyter_code>#sns.set_style("whitegrid", {'axes.edgecolor': '0'})
#sns.set_context("poster", font_scale=1.1)
df_all['date_account_created'] = pd.to_datetime(df_all['date_account_created'])
df_all.date_account_created.value_counts().sort_index().plot(kind='line', linewidth=1.2, color='red')
plt.title("Количество новых пользователей по годам");<jupyter_output><empty_output><jupyter_text>Мы видим экспоненциальный рост количества пользователей по времени.   Часть 4. Закономерности, "инсайты", особенности данных Выше вы уже отмечали найденные особенности в данных. Соберем их здесь вместе:* Распределение целевой переменной говорит о том, что подавляющее большинство пользователей (по факту 90%) попадает в две категории - они либо еще не сделали бронирование, либо сделали первое бронирование в США. На это стоит обратить внимание при моделировании, т.к. сильно обобщенная модель может выдавать только два прогноза - NDF и USA.* Мы видим экспоненциальный рост количества пользователей по времени. Причем скорость роста усиливается с 2014 года. Возможно имеет смысл использовать для моделирования более свежие данные (начиная с 2014 года).* В данных довольно много пропусков (и некорректных значений) по полу и возрасту. Часть некорректных значений по возрасту можно будет восстановить, т.к. некоторые пользователи вместо возраста ввели год рождения (например, 1951). Пропущенные значения связаны, скорее всего, с нежеланием пользователей раскрывать свои личные данные.* Мы не обнаружили различий в распределении целевой переменной по полу и возрасту. Для моделирования потребуется более глубокий анализ и создание новых признаков.* Пользователи с возрастом меньше медианного (половина выборки) более часто делают первое бронирование, чем вторая половина пользователей.* Переменная date_first_booking связана с целевой переменной и заполнена только в обучающей выборке. Она не может быть использована для обучения модели, поэтому ее следует удалить из обучающей выборки.   Часть 5. Предобработка данных   Скорректируем форматы переменных, связанных с датами и временем<jupyter_code>df_train['date_account_created'] = pd.to_datetime(df_train['date_account_created'])
df_test['date_account_created'] = pd.to_datetime(df_test['date_account_created'])

df_train['timestamp_first_active'] = pd.to_datetime(df_train['timestamp_first_active'], format='%Y%m%d%H%M%S')
df_test['timestamp_first_active'] = pd.to_datetime(df_test['timestamp_first_active'], format='%Y%m%d%H%M%S')<jupyter_output><empty_output><jupyter_text>Переменная date_first_booking связана с целевой переменной и заполнена только в обучающей выборке, поэтому ее следует удалить:<jupyter_code>df_train.drop('date_first_booking', axis=1, inplace=True)
df_test.drop('date_first_booking', axis=1, inplace=True)<jupyter_output><empty_output><jupyter_text>Обработка переменной Age Часть пользователей ввели год рождения вместо возраста. Попробуем восстановить их возраст.<jupyter_code>train_with_year_age_mask = df_train['age'] > 1900
df_train.loc[train_with_year_age_mask, 'age'] = 2015 - df_train.loc[train_with_year_age_mask, 'age'] # соревнование 2015г
test_with_year_age_mask = df_test['age'] > 1900
df_test.loc[test_with_year_age_mask, 'age'] = 2015 - df_test.loc[test_with_year_age_mask, 'age']<jupyter_output><empty_output><jupyter_text>Значения возраста больше 100 считаем некорректными и присваиваем им неопределенное значение.<jupyter_code>df_train.loc[df_train.age >= 100, 'age'] = np.nan
df_test.loc[df_test.age >= 100, 'age'] = np.nan<jupyter_output><empty_output><jupyter_text>Аналогично поступаем со значениями возраста менее 15.<jupyter_code>df_train.loc[df_train.age <= 15, 'age'] = np.nan
df_test.loc[df_test.age <= 15, 'age'] = np.nan<jupyter_output><empty_output><jupyter_text>Все пропуски в возрасте заполняем -1.<jupyter_code>df_train['age'].fillna(-1, inplace=True)
df_test['age'].fillna(-1, inplace=True)
df_train['age'] = df_train['age'].astype(int)
df_test['age'] = df_test['age'].astype(int)<jupyter_output><empty_output><jupyter_text>Обработка остальных пропусков Для категориальных переменных с пропущенными значениями преобразуем пропуски к 'unknown' для того, чтобы потом сработал LabelBinarizer.<jupyter_code>df_train['first_affiliate_tracked'].fillna('unknown', inplace=True)
df_test['first_affiliate_tracked'].fillna('unknown', inplace=True)
              
df_train['first_browser'].fillna('unknown', inplace=True)
df_test['first_browser'].fillna('unknown', inplace=True)

df_train['language'].fillna('unknown', inplace=True)
df_test['language'].fillna('unknown', inplace=True)

df_train['gender'].fillna('unknown', inplace=True)
df_test['gender'].fillna('unknown', inplace=True)<jupyter_output><empty_output><jupyter_text>Часть 6. Создание новых признаков и описание этого процесса  Добавим признаки, связанные с сессиями пользователя<jupyter_code>%%time
df_sessions = pd.read_csv('../data/sessions.csv')
df_sessions.shape
df_sessions.head(5)<jupyter_output><empty_output><jupyter_text>Выдвинем гипотезу, что пользоватали, которые чаще пользуются airbnb с мобильного устройства, чаще бронируют жилье в родной стране. Соответственно будет полезно узнать основной используемый девайс в разрезе пользователя.<jupyter_code># Определяем основной девайс пользователя
sessions_device = df_sessions.loc[:, ['user_id', 'device_type', 'secs_elapsed']]
aggregated_lvl1 = sessions_device.groupby(['user_id', 'device_type'], as_index=False, sort=False).aggregate(np.sum)
idx = aggregated_lvl1.groupby(['user_id'], sort=False)['secs_elapsed'].transform(max) == aggregated_lvl1['secs_elapsed']
df_primary = pd.DataFrame(aggregated_lvl1.loc[idx , ['user_id', 'device_type', 'secs_elapsed']])
df_primary.rename(columns = {'device_type':'primary_device', 'secs_elapsed':'primary_secs'}, inplace=True)

df_primary = pd.concat([df_primary[['user_id', 'primary_secs']], 
                        pd.get_dummies(df_primary['primary_device'], 
                                       prefix='prima')
                       ], axis=1)
df_primary.columns = [col.replace('-', '').replace(' ', '_').lower() for col in df_primary.columns]
df_primary.shape<jupyter_output><empty_output><jupyter_text>Также определим второй по популярности девайс в разрезе пользователя<jupyter_code># Определяем второй девайс пользователя
remaining = aggregated_lvl1.drop(aggregated_lvl1.index[idx])
idx = remaining.groupby(['user_id'], sort=False)['secs_elapsed'].transform(max) == remaining['secs_elapsed']
df_secondary = pd.DataFrame(remaining.loc[idx , ['user_id', 'device_type', 'secs_elapsed']])
df_secondary.rename(columns = {'device_type':'secondary_device', 'secs_elapsed':'secondary_secs'}, inplace=True)


df_secondary = pd.concat([df_secondary[['user_id', 'secondary_secs']], 
                        pd.get_dummies(df_secondary['secondary_device'], 
                                       prefix='second')
                       ], axis=1)
df_secondary.columns = [col.replace('-', '').replace(' ', '_').lower() for col in df_secondary.columns]
#df_secondary = convert_to_binary(df=df_secondary, column_to_convert='secondary_device')
#df_secondary.drop('secondary_device', axis=1, inplace=True)
df_secondary.shape<jupyter_output><empty_output><jupyter_text>Дополнительно рассчитаем фичи на основе агрегации различных типов событий из пользовательских сессий.<jupyter_code># Вспомогательная функция для расчета агрегатов по типам событий в разрезе клиентов и разворачивания (pivot) типов по столбцам 
def convert_to_counts(df, id_col, column_to_convert):
    id_list = df[id_col].drop_duplicates()

    df_counts = df.loc[:,[id_col, column_to_convert]]
    df_counts['count'] = 1
    df_counts = df_counts.groupby(by=[id_col, column_to_convert], as_index=False, sort=False).sum()

    new_df = df_counts.pivot(index=id_col, columns=column_to_convert, values='count')
    new_df = new_df.fillna(0)

    # Rename Columns
    categories = list(df[column_to_convert].drop_duplicates())
    for category in categories:
        cat_name = str(category).replace(" ", "_").replace("(", "").replace(")", "").replace("/", "_").replace("-", "").lower()
        col_name = column_to_convert + '_' + cat_name
        new_df.rename(columns = {category:col_name}, inplace=True)

    return new_df
%%time
print("Aggregating actions taken...")
session_actions = df_sessions.loc[:, ['user_id', 'action', 'action_type', 'action_detail']]
columns_to_convert = ['action', 'action_type', 'action_detail']
session_actions = session_actions.fillna('not provided')
first = True

for column in columns_to_convert:
    
    print("Converting " + column + " column...")
    current_data = convert_to_counts(df=session_actions, id_col='user_id', column_to_convert=column)
    
    # If first loop, current data becomes existing data, otherwise merge existing and current
    if first:
        first = False
        actions_data = current_data
    else:
        actions_data = pd.concat([actions_data, current_data], axis=1, join='inner')
actions_data.shape<jupyter_output><empty_output><jupyter_text>Объединим признаки сессий с обучающей и тестовой выборками<jupyter_code># Сначала объединим датасеты с девайсами...
df_primary.set_index('user_id', inplace=True)
df_secondary.set_index('user_id', inplace=True)
device_data = pd.concat([df_primary, df_secondary], axis=1, join="outer")

# ...затем к девайсам добавим события
combined_results = pd.concat([device_data, actions_data], axis=1, join='outer')
df_sessions = combined_results.fillna(0)
primary_cols = [col for col in df_primary.columns if col != 'primary_secs']
secondary_cols = [col for col in df_secondary.columns if col != 'secondary_secs']
df_sessions.shape
df_train.set_index('id', inplace=True)
df_test.set_index('id', inplace=True)
#df_train_sessions = pd.DataFrame(index=df_train['id'])
#df_test_sessions = pd.DataFrame(index=df_test['id'])
df_train = df_train.join(df_sessions, how='inner')
df_test = df_test.join(df_sessions, how='left')
df_train.fillna(0, inplace=True)
df_test.fillna(0, inplace=True)
df_train.shape, df_test.shape<jupyter_output><empty_output><jupyter_text>Выделяем целевую переменную<jupyter_code>y_enc = LabelEncoder()
# Текстовые значения классов (стран) нужно перекодировать в числовые
y = y_enc.fit_transform(df_train[['country_destination']])
df_train.drop('country_destination', axis=1, inplace=True)
categorical_features.remove('country_destination')<jupyter_output><empty_output><jupyter_text>Перекодируем текстовые категориальные переменные<jupyter_code># Так как на входе у нах текстовые значения, то используем LabelBinarizer вместо OneHotEncoder
lab_enc = LabelBinarizer(sparse_output=True)<jupyter_output><empty_output><jupyter_text>LabelBinarizer умеет кодировать только один признак за раз...<jupyter_code>X_train_affiliate_channel_bin = lab_enc.fit_transform(df_train['affiliate_channel'])
X_test_affiliate_channel_bin = lab_enc.transform(df_test['affiliate_channel'])
X_train_affiliate_provider_bin = lab_enc.fit_transform(df_train['affiliate_provider'])
X_test_affiliate_provider_bin = lab_enc.transform(df_test['affiliate_provider'])
X_train_first_affiliate_tracked_bin = lab_enc.fit_transform(df_train['first_affiliate_tracked'])
X_test_first_affiliate_tracked_bin = lab_enc.transform(df_test['first_affiliate_tracked'])
X_train_first_browser_bin = lab_enc.fit_transform(df_train['first_browser'])
X_test_first_browser_bin = lab_enc.transform(df_test['first_browser'])
X_train_first_device_type_bin = lab_enc.fit_transform(df_train['first_device_type'])
X_test_first_device_type_bin = lab_enc.transform(df_test['first_device_type'])
X_train_gender_bin = lab_enc.fit_transform(df_train['gender'])
X_test_gender_bin = lab_enc.transform(df_test['gender'])
X_train_language_bin = lab_enc.fit_transform(df_train['language'])
X_test_language_bin = lab_enc.transform(df_test['language'])
X_train_signup_app_bin = lab_enc.fit_transform(df_train['signup_app'])
X_test_signup_app_bin = lab_enc.transform(df_test['signup_app'])
X_train_signup_method_bin = lab_enc.fit_transform(df_train['signup_method'])
X_test_signup_method_bin = lab_enc.transform(df_test['signup_method'])<jupyter_output><empty_output><jupyter_text>Выделяем признаки, связанные с датами и временем<jupyter_code>df_train['weekday_account_created'] = df_train.date_account_created.dt.weekday
df_test['weekday_account_created'] = df_test.date_account_created.dt.weekday
df_train['day_account_created'] = df_train.date_account_created.dt.day
df_test['day_account_created'] = df_test.date_account_created.dt.day
df_train['month_account_created'] = df_train.date_account_created.dt.month
df_test['month_account_created'] = df_test.date_account_created.dt.month
df_train['quarter_account_created'] = df_train.date_account_created.dt.quarter
df_test['quarter_account_created'] = df_test.date_account_created.dt.quarter
df_train['year_account_created'] = df_train.date_account_created.dt.year
df_test['year_account_created'] = df_test.date_account_created.dt.year
df_train['hour_first_active'] = df_train.timestamp_first_active.dt.hour
df_test['hour_first_active'] = df_test.timestamp_first_active.dt.hour
df_train['weekday_first_active'] = df_train.timestamp_first_active.dt.weekday
df_test['weekday_first_active'] = df_test.timestamp_first_active.dt.weekday
df_train['day_first_active'] = df_train.timestamp_first_active.dt.day
df_test['day_first_active'] = df_test.timestamp_first_active.dt.day
df_train['month_first_active'] = df_train.timestamp_first_active.dt.month
df_test['month_first_active'] = df_test.timestamp_first_active.dt.month
df_train['quarter_first_active'] = df_train.timestamp_first_active.dt.quarter
df_test['quarter_first_active'] = df_test.timestamp_first_active.dt.quarter
df_train['year_first_active'] = df_train.timestamp_first_active.dt.year
df_test['year_first_active'] = df_test.timestamp_first_active.dt.year
df_train['time_lag'] = (df_train['date_account_created'] - df_train['timestamp_first_active']).dt.days
df_test['time_lag'] = (df_test['date_account_created'] - df_test['timestamp_first_active']).dt.days<jupyter_output><empty_output><jupyter_text>Перекодируем числовые категориальные переменные<jupyter_code>oh_enc = OneHotEncoder(handle_unknown='ignore')
X_train_cat_bin = oh_enc.fit_transform(df_train[['signup_flow', 
                                                'weekday_account_created', 
                                                'day_account_created', 
                                                'month_account_created', 
                                                'quarter_account_created', 
                                                'year_account_created', 
                                                'hour_first_active', 
                                                'weekday_first_active', 
                                                'day_first_active', 
                                                'month_first_active', 
                                                'quarter_first_active', 
                                                'year_first_active']])

X_test_cat_bin = oh_enc.transform(df_test[['signup_flow', 
                                           'weekday_account_created', 
                                           'day_account_created', 
                                           'month_account_created', 
                                           'quarter_account_created', 
                                           'year_account_created', 
                                           'hour_first_active', 
                                           'weekday_first_active', 
                                           'day_first_active', 
                                           'month_first_active', 
                                           'quarter_first_active', 
                                           'year_first_active']])
X_train_cat_bin.shape, X_test_cat_bin.shape
# ((213451, 160), (62096, 160))<jupyter_output><empty_output><jupyter_text>Масштабирование признаков Так как мы планируем попробовать в т.ч. линейные модели, то необходимо масштабирование признаков.<jupyter_code>scaler = StandardScaler()
X_train_age_scaled = scaler.fit_transform(df_train[['age']])
X_test_age_scaled = scaler.transform(df_test[['age']])
X_train_time_lag_scaled = scaler.fit_transform(df_train[['time_lag']])
X_test_time_lag_scaled = scaler.transform(df_test[['time_lag']])
X_train_primary_secs_scaled = scaler.fit_transform(df_train[['primary_secs']])
X_test_primary_secs_scaled = scaler.transform(df_test[['primary_secs']])
X_train_actions_scaled = scaler.fit_transform(df_train[actions_data.columns])
X_test_actions_scaled = scaler.transform(df_test[actions_data.columns])<jupyter_output><empty_output><jupyter_text>Объединим все созданные признаки в единый датасет<jupyter_code>X_train = csr_matrix(
            hstack(
                [X_train_affiliate_channel_bin,
                 X_train_affiliate_provider_bin,
                 X_train_first_affiliate_tracked_bin,
                 X_train_first_browser_bin,
                 X_train_first_device_type_bin,
                 X_train_gender_bin,
                 X_train_language_bin,
                 X_train_signup_app_bin,
                 X_train_signup_method_bin,
                 X_train_cat_bin,
                 X_train_age_scaled,
                 X_train_time_lag_scaled,
                 X_train_actions_scaled,
                 df_train[primary_cols]
                ]
            )
)
X_test = csr_matrix(
            hstack(
                [X_test_affiliate_channel_bin,
                 X_test_affiliate_provider_bin,
                 X_test_first_affiliate_tracked_bin,
                 X_test_first_browser_bin,
                 X_test_first_device_type_bin,
                 X_test_gender_bin,
                 X_test_language_bin,
                 X_test_signup_app_bin,
                 X_test_signup_method_bin,
                 X_test_cat_bin,
                 X_test_age_scaled,
                 X_test_time_lag_scaled,
                 X_test_actions_scaled,
                 df_test[primary_cols]
                ]
            )
)
X_train.shape, X_test.shape<jupyter_output><empty_output><jupyter_text>Выделяем обучающую выборку и валидационную (отложенную).<jupyter_code>X_train_part, X_valid, y_train_part, y_valid = train_test_split(X_train, y, test_size=0.3, random_state=17)<jupyter_output><empty_output><jupyter_text>Часть 7. Выбор метрики В данной задаче по каждому пользователю из тестовой выборки нам нужно спрогнозировать 5 стран, разместив прогнозы в порядке уменьшения вероятности первого бронирования. В идеальном прогнозе для каждого пользователя на первом месте будет идти "истинная" страна первого бронирования. В хорошем же прогнозе чем выше "истинная" страна в списке из 5 спрогнозированных стран, тем лучше. Это чем-то похоже на ранжирование поисковой выдачи, поэтому применяется соответствующая метрика качества.В качестве основной метрики для оценки результатов модели мы будем использовать (вслед за организаторами соответствующего соревнования Kaggle) метрику [NDCG (Normalized discounted cumulative gain)](https://en.wikipedia.org/wiki/Discounted_cumulative_gain). Это метрика качества ранжирования, которая широко используется в информационном поиске, а также в некоторых задачах многолассовой классификации (наш случай). NDCG является нормализованной (нормированной) версией метрики Discounted Cumulative Gain (DCG), которая определяется следующим образом:$$\large \begin{array}{rcl}DCG_k = \sum_{i=1}^{k}\dfrac{2^{rel_i}-1}{\log_{2}(i+1)}\end{array}$$$rel_i$ - это релевантность нахождения объекта поисковой выдачи на позиции $i$. В нашем случае "истинная" страна имеет $rel_i = 1$, а все остальные $rel_i = 0$. Поэтому чем выше истинная страна в ранкинге, тем выше скор DCG.$k$ - параметр метрики - количество объектов, которые рассматриваются в ранкинге. В нашем случае $k = 5$.Для получения NDCG нужно DCG разделить на "идеальный" DCG для набора документов, которые все правильно отсортированы по их релевантности. В нашем случае IDCG = 1, т.к. у нас только одна релевантная страна и она должна быть расположена на первом месте, следовательно $\dfrac{2^{1}-1}{\log_{2}(1+1)}=1$.  Часть 8. Выбор модели Так как мы имеем дело с задачей многоклассовой классификации, то попробовуем использовать два класса моделей: * Логистическую регрессию (LogisticRegression) - простой и быстрый вариант.* Градиентный бустинг (XGBoost) - потенциально обеспечит более высокую точность.   Часть 9. Кросс-валидация, подбор параметров  _Логистическая регрессия_ Для начала построим модель логистической регрессии с параметрами по умолчанию.<jupyter_code>%%time
clf_logit = LogisticRegression(random_state=17, 
                               multi_class='multinomial',
                               n_jobs=4,
                               solver='lbfgs'
                              )
clf_logit.fit(X_train_part, y_train_part)<jupyter_output><empty_output><jupyter_text>Прогноз для отложенной и тестовой выборки<jupyter_code>y_predict = clf_logit.predict_proba(X_valid)
%%time
my_ndcg(y_valid, y_predict, k=5)
%%time
clf_logit.fit(X_train, y)
y_predict = clf_logit.predict_proba(X_test)
create_submission(y_predict, df_test.index, y_enc, 'sub_log_base.csv')<jupyter_output><empty_output><jupyter_text>Данный прогноз дает показатель на public LB = 0.85764, а на private LB - 0.86166.  Кросс-валидация логистической регрессии Теперь выполним кросс-валидацию модели логистической регрессии. Будем подбирать параметр регуляризации $C$ на кросс-валидации по 5 фолдам.<jupyter_code>C_vals = np.logspace(-2, 2, 5)
log_cv = LogisticRegressionCV(Cs=C_vals, 
                              cv=5, 
                              random_state=17, 
                              multi_class='multinomial', 
                              solver='lbfgs', 
                              scoring=ndcg_scorer, 
                              n_jobs=-1)
%%time
log_cv.fit(X_train_part, y_train_part)
log_cv.C_<jupyter_output><empty_output><jupyter_text>Оптимальный параметр C = 0.01.  Прогноз для отложенной и тестовой выборки (логистическая регрессия) Выполним прогнозы с учетом оптимального значения параметра C, полученного на кросс-валидации.<jupyter_code>%%time
clf_logit = LogisticRegression(C=0.01, 
                               random_state=17, 
                               n_jobs=4,
                               multi_class='multinomial', 
                               solver='lbfgs')
clf_logit.fit(X_train_part, y_train_part)
y_predict = clf_logit.predict_proba(X_valid)
%%time
my_ndcg(y_valid, y_predict, k=5)
%%time
clf_logit.fit(X_train, y)
y_predict = clf_logit.predict_proba(X_test)
create_submission(y_predict, df_test.index, y_enc, 'sub_tmp_cv.csv')<jupyter_output><empty_output><jupyter_text>По результатам кросс-валидации удалось улучшить скор на public LB - до 0.86439, на private LB - 0.86825.  Построим кривые валидации<jupyter_code>C_vals = np.logspace(-2, 1, 5)
clf_logit = LogisticRegression(random_state=17, 
                               n_jobs=-1,
                               multi_class='multinomial', 
                               solver='lbfgs')
%%time
val_train, val_test = validation_curve(clf_logit, 
                                       X_train, 
                                       y, 
                                       param_name='C', 
                                       param_range=C_vals, 
                                       cv=5,
                                       scoring='neg_log_loss',
                                       n_jobs=-1,
                                       verbose=1)
def plot_with_err(x, data, **kwargs):
    mu, std = data.mean(1), data.std(1)
    lines = plt.plot(x, mu, '-', **kwargs)
    plt.fill_between(x, mu - std, mu + std, edgecolor='none',
                     facecolor=lines[0].get_color(), alpha=0.2)


plt.rcParams['figure.figsize'] = 16,9
plot_with_err(C_vals, val_train, label='training scores')
plot_with_err(C_vals, val_test, label='validation scores')
plt.xlabel(r'$C$'); plt.ylabel('log loss')
plt.legend();<jupyter_output><empty_output><jupyter_text>На основе анализа кривых валидации можно сделать вывод, что наша модель переобучена, т.к. в LogisticRegression меньшим значениям "C" соответствует большая регуляризация.  Построим кривые обучения<jupyter_code>from sklearn.metrics import log_loss
log_loss_scorer = make_scorer(lambda y_true, y_pred: log_loss(y_true, y_pred, labels=np.unique(y_true)), needs_proba=True)
clf_logit = LogisticRegression(random_state=17, 
                               n_jobs=4,
                               C=0.01,
                               multi_class='multinomial', 
                               solver='lbfgs')
    
N_train, val_train, val_test = learning_curve(clf_logit,
                                              X_train, y, 
                                              #groups=y,
                                              #train_sizes=train_sizes, 
                                              cv=3,
                                              n_jobs=1, 
                                              verbose=1,
                                              scoring=ndcg_scorer)
plt.rcParams['figure.figsize'] = 16,9
plot_with_err(N_train, val_train, label='training scores')
plot_with_err(N_train, val_test, label='validation scores')
plt.xlabel('Training Set Size'); plt.ylabel('log loss')
plt.title("Learning curves")
plt.legend()<jupyter_output><empty_output><jupyter_text>На кривой обучения мы видим, что метрики качества на обучающающей и валидационной выборках сходятся после примерно 15000 наблюдений. Далее они практически не меняются.  _Градиентный бустинг (XGBoost)_  Подбираем базовое количество деревьев XGBoost имеет большое количество тонких настроек. Вначале мы попробуем подобрать оптимальное количество деревьев (n_estimators) при помощи встроенной функции кросс-валидации xgboost. Из-за ограничения в вычислительных ресурсах мы не будем проверять значения n_estimators больше 100.<jupyter_code># Кастомная реализация метрики NDCG
def ndcg5_score(preds, dtrain):
    labels = dtrain.get_label()
    top = []

    for i in range(preds.shape[0]):
        top.append(np.argsort(preds[i])[::-1][:5])

    mat = np.reshape(np.repeat(labels,np.shape(top)[1]) == np.array(top).ravel(),np.array(top).shape).astype(int)
    score = np.mean(np.sum(mat/np.log2(np.arange(2, mat.shape[1] + 2)),axis = 1))
    return 'ndcg5', score
%%time
xgtrain = xgb.DMatrix(X_train_part, label=y_train_part)
param = {
    'learning_rate': 0.1,
    'max_depth': 5,
    'n_estimators': 100,
    'objective': 'multi:softprob',
    'num_class': 12,
    'gamma': 0,
    'min_child_weight': 1,
    'subsample': 0.8,
    'colsample_bytree': 0.8,
    'scale_pos_weight': 1,
    'nthread': 8,
    'n_jobs': -1,
    'seed': 17
}<jupyter_output><empty_output><jupyter_text>Запускаем встроенную функцию кросс-валидации xgboost<jupyter_code>%%time
cvresult = xgb.cv(param, 
                  xgtrain, 
                  num_boost_round=100, 
                  metrics=['mlogloss'], 
                  feval=ndcg5_score, 
                  maximize=True,
                  #nfold=5,
                  seed=17, 
                  early_stopping_rounds=50, 
                  verbose_eval=True)
cvresult.shape[0]<jupyter_output><empty_output><jupyter_text>Мы уперлись в ограничение на количество деревьев. Возможно, имеется потенциал дальнейшего улучшения, но из-за вычислительных ограничений мы остановимся на n_estimators=100.  Прогноз на отложенной выборке<jupyter_code>xgb1 = XGBClassifier(
     learning_rate=0.1,
     min_child_weight=1,
     n_estimators=100,
     max_depth=5,
     gamma=0,
     subsample=0.8,
     colsample_bytree=0.8,
     objective='multi:softprob',
     num_class=12,
     scale_pos_weight=1,
     reg_alpha=0,
     reg_lambda=1,
     nthread=8,
     n_jobs=-1,
     seed=17
)
%%time
xgb1.fit(X_train_part, y_train_part)
y_predict = xgb1.predict_proba(X_valid)
%%time
my_ndcg(y_valid, y_predict, k=5)
%%time
xgb1.fit(X_train, y)
y_predict = xgb1.predict_proba(X_test)
create_submission(y_predict, df_test.index, y_enc, 'sub_xgb_100.csv')<jupyter_output><empty_output><jupyter_text>Данный сабмит получил public LB - 0.88039, private LB - 0.88552  Настройка параметров XGBoost Из-за ограничения вычислительных ресурсов и особенностей пространства признаков задачи (много разреженных переменных) сделать полноценный поиск оптимальных параметров XGBoost не представляется возможным. Нам не удалось за разумное время посчитать при помощи GridSearch и кросс-валидации на 3 фолда оптимальные параметры, поэтому мы использовали более простую схему ручного перебора - без кросс-валидации, но с оценкой на валидационной (отложенной) выборке. В результате получены следующие "оптимальные" параметры:<jupyter_code>xgb1 = XGBClassifier(
     learning_rate=0.1,
     min_child_weight=1,
     n_estimators=100,
     max_depth=5,
     gamma=0.5,
     subsample=0.8,
     colsample_bytree=0.8,
     objective='multi:softprob',
     num_class=12,
     scale_pos_weight=1,
     reg_alpha=0,
     reg_lambda=1,
     nthread=8,
     n_jobs=-1,
     seed=17
)<jupyter_output><empty_output><jupyter_text>Прогноз на отложенной выборке<jupyter_code>%%time
xgb1.fit(X_train_part, y_train_part)
y_predict = xgb1.predict_proba(X_valid)
my_ndcg(y_valid, y_predict, k=5)<jupyter_output><empty_output><jupyter_text>Прогноз на тестовой выборке и сабмит<jupyter_code>%%time
xgb1.fit(X_train, y)
y_predict = xgb1.predict_proba(X_test)
create_submission(y_predict, df_test.index, y_enc, 'sub_xgb_100_tune.csv')<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.10011789244581482, 'fraction_numerical': 0.010632991747528794, 'mean_word_length': 4.211981566820277, 'pattern_counts': {'":': 0, '<': 198, '<?xml version=': 0, '>': 202, 'https://': 3, 'lorem ipsum': 0, 'www.': 1, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 70, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '47', 'dir': 'jupyter-structured-clean-dedup', 'id': '226500', 'license': 'Unlicense', 'n_tokens_mistral': 15996, 'n_tokens_neox': 17208, 'n_words': 3010, 'path': 'jupyter_russian/projects_individual/project_airbnb_booking_melnik.ipynb', 'repo_name': 'salman394/AI-ml--course', 'size': '44130'}