{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Проект Bank Marketing\n", "\n", "Автор: Олег Акимов (Slack: @Oleg)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 0. Постановка задачи" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "В ходе проекта решается задача прогнозирования положительного отклика (срочный вклад) на прямые звонки из банка. \n", "По-сути, задача является задачей банковского скоринга, т.е. по характеристикам клиента (потенциального клиента), предсказывается его поведение (невозврат кредита, желание открыть вклад и т.д.). В департаментах рисков банков существуеют строгие требования к интерпретируемости модели, поэтому я хочу рассмотреть 2 решения: \"банковское\" и свободное. Задача обзвона клиентов не является задачей определения риска дефолта, поэтому на неё не накладывается условие интерпретируемости, однако задача схожая, и думаю интересно на ее примере показать решение с ограничением (интерпретируемость).\n", "\n", "Источник данных UCI: https://archive.ics.uci.edu/ml/datasets/bank+marketing" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. Описание набора данных и признаков" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Датасет содержит 41188 объектов, для кадого из которых задан 21 признак, в том числе 1 целевой. \n", "\n", "Рассмотрим по порядку все имеющиеся признаки: \n", "1) **age** - возраст потенциального клиента в годах \n", "2) **job** - профессия (Возможны следующие значения: *admin., blue-collar, entrepreneur, housemaid, management, retired, self-employed, services, student, technician, unemployed, unknown*) \n", "3) **marital** - семейное положение (Значения: *divorced, married, single, unknown*) \n", "4) **education** - образование (Значения: *basic.4y, basic.6y, basic.9y, high.school, illiterate, professional.course, university.degree, unknown*) \n", "5) **default** - есть ли дефолт по кредиту (Значения: *no, yes, unknown*) \n", "6) **housing** - есть ли кредит на жильё (Значения: *no, yes, unknown*) \n", "7) **loan** - есть ли потребительский кредит (Значения: *no, yes, unknown*) \n", "8) **contact** - способ связи c потенциальным клиентом (Значения: *cellular, telephone*) \n", "9) **month** - номер месяца, когда был крайний контакт с клиентом (Значения: *jan, feb, mar, ... , nov, dec*) \n", "10) **day_of_week** - день недели, когда был крайний контакт с клиентом (Значения: *mon, tue, wed, thu, fri*) \n", "11) **duration** - продолжительность крайнего звонка клиенту (Имеется ввиду, звонок, результат которого мы прогнозируем, в секундах) \n", "12) **campaign** - количество контактов с данным клиентом в течение текущей компании \n", "13) **pdays** - количество дней, прошедшее с предыдущего контакта (999 для новых клиентов) \n", "14) **previous** - количество контактов с данным клиенто до текущей компании \n", "15) **poutcome** - результат предыдущей маркетинговой компании (Значения: *failure, nonexistent, success*) \n", "16) **emp.var.rate** - коэффициент изменения занятости - квартальный показатель \n", "17) **cons.price.idx** - индекс потребительских цен - месячный показатель \n", "18) **cons.conf.idx** - индекс доверия потребителей - ежемесячный показатель \n", "19) **euribor3m** - euribor 3-месячный курс - дневной индикатор \n", "20) **nr.employed** - количество сотрудников - квартальный показатель \n", "\n", "Целевой признак:\n", "\n", "21) **y** - подписал ли клиент срочный вклад (Значения: *yes, no*)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2-3. Первичный анализ данных и первичный визуальный анализ данных" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Загрузим необходимые библиотеки:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import pandas as pd\n", "import numpy as np\n", "import seaborn as sns\n", "from matplotlib import pyplot as plt\n", "%matplotlib inline\n", "from sklearn.model_selection import train_test_split\n", "from sklearn.ensemble import RandomForestClassifier\n", "from sklearn.model_selection import cross_val_score\n", "from sklearn.linear_model import LogisticRegression\n", "from sklearn.model_selection import GridSearchCV\n", "from sklearn.preprocessing import LabelEncoder\n", "from sklearn.metrics import recall_score\n", "from sklearn.metrics import roc_auc_score, roc_curve\n", "from sklearn.model_selection import train_test_split, StratifiedKFold\n", "from imblearn.over_sampling import RandomOverSampler\n", "from sklearn import preprocessing\n", "from sklearn.model_selection import validation_curve\n", "from sklearn.svm import SVC\n", "import time\n", "from sklearn.linear_model import SGDClassifier\n", "from xgboost import XGBClassifier\n", "import warnings\n", "from sklearn.tree import DecisionTreeClassifier\n", "warnings.filterwarnings('ignore')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Загрузим данные из файла и зададим параметры вывода датафрейма, чтобы все колонки было видно." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "raw_data = pd.read_csv('bank-additional-full.csv', header = 0, sep = ';')\n", "data = raw_data.copy()\n", "pd.set_option('display.max_columns', 100)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Посмотрим на размер загруженного датасета:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data.shape" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Посмотрим, на содержимое датафрейма:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data.head(5)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Проверим, есть ли в данных отсутствующие значения:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data.isnull().sum()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как видим, данные полные, пропусков нет, поэтому нет необходимости заполнять пропуски. \n", "Далее проверим данные на выбросы. Для начала сравним минимальное и максимальное значение со средним (для численных признаков):" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data.describe(include = ['int64', 'float64'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "В целом, по этим данным, нельзя сказать, что в данных есть откровенные выбросы. Однанако, такой проверки недостаточно, \n", "желательно ещё посмотреть графики зависимости целевого признака от каждого из признако, но это мы сделаем чуть позже, \n", "когда будем визуализировать признаки и зависимости. \n", "\n", "Далее посмотрим на распределения числовых признаков. Для этого воспользуемся библиотекой Seaborn." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "categorial = []\n", "numerical = []\n", "for feature in data.columns:\n", " if data[feature].dtype == object:\n", " categorial.append(feature)\n", " else:\n", " numerical.append(feature)\n", "\n", "data[numerical].hist(figsize=(20,12), bins=100, color='lightgreen')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Из гистограмм мы видим, что **для каждого численного признака есть одно или несколько доминирующих отрезков значений**, \n", "из-за чего мы получили ярко выраженные пики. \n", "\n", "Далее осмотрим на категориальные признаки:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data.describe(include = ['object'])" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "plt.rcParams['axes.labelsize'] = 20\n", "plt.rcParams['axes.titlesize'] = 20\n", "plt.rcParams['font.size'] = 20\n", "\n", "fig, axes = plt.subplots(ncols=4, nrows = 3, figsize=(24, 18))\n", "plt.subplots_adjust(left=None, bottom=None, right=None, top=None, wspace=None, hspace=0.4)\n", "\n", "for i in range(len(categorial)):\n", " data[categorial[i]].value_counts(normalize=True).plot(kind='bar', label=categorial[i], ax=axes[i//4, i%4], color='lightgreen')\n", " axes[i//4, i%4].set_title(categorial[i])\n", "plt.tight_layout()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как видим, для многих признаков, какая-то из групп привалирует, например, в датасете более половины клиентов женаты/замужем. \n", "\n", "Помимо этого мы видим, что целевой признак несбалансирован. **Количество положительных исходов существенно ниже, чем отрицательных**, что вполне естественно для телефонного маркетинга. Вследствие этого, возникает проблема с тем, что многие методы чувствительны к несбалансированности признаков. Данную проблему мы дальше постораемся решить." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Посмотрим на матрицу корреляции (для числовых признаков):" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "correlation_table = data.corr()\n", "correlation_table" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Визуализируем матрицу корреляции:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "sns.heatmap(correlation_table)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как видно из таблицы и тепловой карты **euribor3m и nr.employed сильно коррелируют с emp.var.rate**, впоследствии, на этапе отбора признаков мы их удалим, когда будем отбирать признаки." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Целевой признак показывает положительный ли результат телефонного звонка в ходе маркетинговой компании. \n", "Обозначим положительный исход как 1, отрицательный как 0:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data['y'] = data['y'].map({'yes': 1, 'no': 0})" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Посмотрим на визуализированные зависимости вещественных признаков от целевого признака:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "fig, axes = plt.subplots(ncols=4, nrows = 3, figsize=(24, 18))\n", "plt.subplots_adjust(left=None, bottom=None, right=None, top=None, wspace=None, hspace=0.4)\n", "\n", "for i in range(len(numerical)):\n", " data.plot(x=numerical[i], y = 'y', label=numerical[i], ax=axes[i//4, i%4], kind='scatter', color='green')\n", " axes[i//4, i%4].set_title(numerical[i])\n", "plt.tight_layout()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как видим, есть точки, которые можно интерпретировать как выбросы есть, однако, я бы не спешил их удалять, т.к. мне они не кажутся выбросами в чистом виде. Т.е. это врядли ошибки и выбросы слишком сильные, пока я их оставлю. К тому же, для начала я намерен использовать RandomForest, а деревья, как известно, устойчивы к выбросам." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Категориальные признаки необходимо сделать числовыми, для этого я использую LabelEncoder. OneHotEncoder не использую, т.к. для категориальных признаков в дальнейшем буду считать WOE (см. дальше), поэтому нам неважно как занумеровывать категории." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "categorial.remove('y')\n", "data[categorial] = data[categorial].apply(LabelEncoder().fit_transform)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "fig, axes = plt.subplots(ncols=4, nrows = 3, figsize=(24, 18))\n", "plt.subplots_adjust(left=None, bottom=None, right=None, top=None, wspace=None, hspace=0.4)\n", "\n", "for i in range(len(categorial)):\n", " sns.countplot(x=categorial[i], hue='y', data=data, ax=axes[i//4, i%4])\n", " axes[i//4, i%4].set_title(categorial[i])\n", "\n", "plt.tight_layout()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Из данных графиков видно некоторые зависимости, но лучше посмотреть в несколько другом виде. \n", "Визуализируем **долю положительных откликов** по группам:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "fig, axes = plt.subplots(ncols=4, nrows = 3, figsize=(24, 18))\n", "plt.subplots_adjust(left=None, bottom=None, right=None, top=None, wspace=None, hspace=0.4)\n", "\n", "for i in range(len(categorial)):\n", " data.groupby(categorial[i])['y'].mean().plot(kind='bar', ax=axes[i//4, i%4], color='green')\n", " axes[i//4, i%4].set_title(categorial[i])\n", "\n", "plt.tight_layout()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "В таком виде, графики уже более интересные. Дак мы видим, для многих признаков, шанс положительного отклика существенно выше. \n", "Как я уже говорил, я панирую использовать WOE для работы с категориальными признаками. \n", "Помимо этого, исходя из данных графиков, напрашивается идея сделать более укрупненные категории. Например для образования объединить категории следующим образом: \n", "1) 0, 3, 5 \n", "2) 1, 2 \n", "3) 6, 7 \n", "4) 4 \n", "Однако, прелесть WOE в том, что как раз для значений признака, для которых доля положительных откликов сходная, будут близки и значения WOE. Поэтому, из графиков понятно, что применение WOE в данной задаче обосновано и желательно. \n", "\n", "Также мы видим, что признаки **housing**, **loan** и **day_of_week** врядли нам чем-то помогут, т.к. судя по графикам, доля положительных откликов от них практически не зависит. *Осторожно, спойлеры.* Далее мы их удалим, но перед этим посмотрим на них с точки зрения Information Value и увидим, что они действительно неинформативны." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4. Инсайты \n", " \n", "Пропусков в данных у нас нет, явных выбросов, которые стоило бы вырезать, на мой взгляд тоже нет. \n", " \n", "**euribor3m** и **nr.employed** сильно коррелируют с **emp.var.rate**. \n", "Напомню, **emp.var.rate** - коэффициент изменения занятости - квартальный показатель, **euribor3m** - euribor 3-месячный курс - дневной индикатор, **nr.employed** - количество сотрудников - квартальный показатель. Корреляция изменения занятости и собственно количества занятых вопросов не вызывает, а вот корреляция с Euribor (Euro Interbank Offered Rate, европейская межбанковская ставка предложения) - это интересно. Межбанковская ставка - это один из основных макропараметров экономики государства, т.е. влияет на темпы роста экономики, которые естественным образом влияют на занятость. Логично, но такая сильная корреляция подозрительна :). Слишком быстрый отклик рынка труда." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 5. Выбор метрики" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Цель банка состоит в том, чтобы снизить затраты на обзвон всех клиентов, сузив группу обзвона до той группы, для которой наиболее высока вероятность положительного отклика. Для банка важно снизить количество звонков не потеряв при этом клиентов, поэтому наиболее **критичными для банка являются ошибки FalseNegative**, т.к. банк теряет клиентов. Затраты на лишний звонок ниже, чем убыток от потерянного клиента. Исходя из этого, предпочитетльной метрикой будет **Recall**, которая показывает, какую долю объектов положительного класса из всех объектов положительного класса нашел алгоритм. Однако, **Recall** метрика слишком \"однобокая\", т.к. стимулирует модель ставить всем объектам метки \"1\". Поэтому, более предпочтительным я считаю использование **ROC-AUC**, но при этом считаю важным следить отдельно за значением **Recall**. Ниже проиллюстрируем, почему **не стоит использовать Acuracy**" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Что будет, если мы всем объектам поставим метки \"0\"?" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print ('Accuracy = ', round((1 - data['y'].mean())*100, 1))\n", "print ('Recall = ', round(recall_score(data['y'], [0]*len(data['y'])), 1))\n", "print ('ROC-AUC = ', round(roc_auc_score(data['y'], [0]*len(data['y'])), 1))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Таким образом, мы имеем точность модели 88.7%, однако банк имеет нулевую прибыль, т.к. обзвон потенциальных клиентов не производился. \n", "Основная причина такой ситуации в том, что целевые классы очень несбалансированны, положительных откликов всего 11.3%, \n", "поэтому мы предпочтём **ROC-AUC**" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data['y'].value_counts(normalize=True).apply(lambda x: round(x*100, 1))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "А если мы всем объектам поставим метки \"1\"?" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print ('Accuracy = ', round((data['y'].mean())*100, 1))\n", "print ('Recall = ', round(recall_score(data['y'], [1]*len(data['y'])), 1))\n", "print ('ROC-AUC = ', round(roc_auc_score(data['y'], [1]*len(data['y'])), 1))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как мы видим, recall максимальный, но при этом мы обзваниваем всех" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 6. Выбор модели" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как я говорил в начале своей работы, я разделю решение на 2 части: \n", "**1) \"Банковское\"**: логистическая регрессия + WOE. Выбор обоснован тем, что Логистическая Регрессия хорошо интерпретируема и используется в реальном банковском моделировании рисков. Однако, Логистическая Регрессия не работает с категориальными признаками, вернее сказать, что работает, но для этого значения категориальных признаков должны быть сравнимы. В том числе для этого вводится понятие WOE (Weight of evidence). \n", "**2) Свободное от ограничений решение**: в нем я хочу исследовать RandomForest и XGBoost. Делаю ставку на RandomForest, т.к. он устойчив к выбросам (которые я не удалял, т.к. не уверен, что они не важны) и т.к. многие признаки категориальные, значит деревья должны себя показать лучше на таких признаках. Данные модели будут тоже использоваться в сочетании с расчетом WOE (см. конец пункта 4). Также попробую XGBoost на деревьях в соответствии с той же логикой. И в итоге, попробую их объединить." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 7. Предобработка данных" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Удалим признаки **euribor3m** и **nr.employed**, которые сильно коррелируют с **emp.var.rate** (см. пункт 2-3)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data.drop(['euribor3m', 'nr.employed'], axis=1, inplace=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Удалим признак **duration**, т.к. он показывает продолжительность разговора, необходимость которого мы хотим предсказать" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data.drop(['duration'], axis=1, inplace=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Посчитаем WOE**:" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "![title](../../img/WOE.jpg)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Функция расчёта **Information Value**:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def Information_Value(x, y):\n", " df = pd.DataFrame({'x': x, 'y': y})\n", " good = df.groupby('x')['y'].sum() / np.sum(df['y'])\n", " bad = (df.groupby('x')['y'].count() - df.groupby('x')['y'].sum()) / (len(df['y']) - np.sum(df['y']))\n", " WOE = np.log((good+0.000001) / bad)\n", " IV = (good - bad)*WOE\n", " return IV.sum()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Функция расчёта **WOE**:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def WOE(x, y):\n", " df = pd.DataFrame({'x': x, 'y': y})\n", " good = df.groupby('x')['y'].sum() / np.sum(df['y'])\n", " bad = (df.groupby('x')['y'].count() - df.groupby('x')['y'].sum()) / (len(df['y']) - np.sum(df['y']))\n", " WOE = np.log((good+0.000001)/ bad)\n", " WOE = pd.Series(WOE).to_dict()\n", " return x.apply(lambda x: WOE.get(x))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "WOE можно считать для значений признака, однако, учитывая формулу WOE, делать это для вещественных признаков не очень правильно. Лучше разбить диапазон значений на интервалы, закодировать их и посчитать WOE. Отсюда вытекает задача, как оптимально разбить значения признаков на интервалы. Для решения этой задачи, я решил для каждого признака обучать DecisionTreeClassifier и из него получать границы разбиение признака, при которых score максимальный." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Функция, которая возвращает границы разбиений\n", "def get_bondaries(x_bondaries, y_bondaries):\n", " parameters = {'max_depth':[x for x in range(1, 21)], 'min_samples_leaf': [5, 10, 20, 30, 50, 70, 100, 150, 200, 300, 400, 500, 1000, 2000, 5000, 10000, 20000]}\n", " dtc = DecisionTreeClassifier(random_state=17)\n", " skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=17)\n", " clf = GridSearchCV(dtc, parameters, scoring='roc_auc', cv=skf)\n", " clf.fit(pd.DataFrame(x_bondaries), y_bondaries)\n", " print('Best parameters for DT: ', clf.best_params_)\n", " print('ROC_AUC score: ', round(clf.best_score_, 4))\n", " tree = clf.best_estimator_\n", " tree.fit(pd.DataFrame(x_bondaries), y_bondaries)\n", " print('Boundaries: ', np.sort([x for x in tree.tree_.threshold if x!=-2]))\n", " return np.sort([x for x in tree.tree_.threshold if x!=-2])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Расчитываем границы разбиений для каждого признака. Посмотрим параметры дерева, которое делит признаки на интервалы, а так же на само разбиение." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "bondaries = dict()\n", "for col in data.columns:\n", " if col != 'y':\n", " print (col)\n", " bondaries[col] = get_bondaries(data[col], data['y'])\n", " print ('--------------')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Функция каторая режет признак на интервалы и кодирует их\n", "def splitter(x, col_bondaries):\n", " for i in range(len(col_bondaries)):\n", " if i>0:\n", " if x>col_bondaries[i-1] and x<=col_bondaries[i]:\n", " return i\n", " if i==0:\n", " if x<=col_bondaries[i]:\n", " return i\n", " if i==len(col_bondaries)-1:\n", " if x>col_bondaries[i]:\n", " return i+1" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Считаем WOE для признаков:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data_woe = pd.DataFrame()\n", "for col in data.columns:\n", " if col != 'y' and col!='default' and col!='previous':\n", " data_woe[col] = data[col].apply(lambda x: splitter(x, bondaries[col]))\n", " data_woe[col] = WOE(data_woe[col], data['y'])\n", " if col=='default' or col=='previous':\n", " data_woe[col] = data[col]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Посмотрим на **Information Value** категориальных признаков:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "for feature in data_woe.columns:\n", " print('IV of ', feature, ' = ', Information_Value(data_woe[feature], data['y']), '\\n')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Сравнивая Information value, видим, что признаки **housing**, **loan** и **day_of_week** являются неинформативными, поэтому мы их можем удалить. Кстати говоря, это же мы видели, когда строили графики, показывающие долю положительных откликов в зависимости от значения признака." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data_woe.drop(['housing', 'loan', 'day_of_week'], axis=1, inplace=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 8. Кросс-валидация и настройка гиперпараметров" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Отложим выборку для теста:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df, df_test, y, y_test = train_test_split(data_woe, data['y'], test_size=0.3, stratify=data['y'], random_state=17)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 8.1 Логистическая регрессия" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Подберем параметры с помощью **GridSearchCV**:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "parameters = {'penalty':['l1', 'l2'], 'C':[0.12, 0.11, 0.1, 0.09, 0.08, 0.07, 0.06, 0.05, 0.04, 0.03, 0.02, 0.01], 'class_weight':[None, 'balanced']}\n", "LR = LogisticRegression(random_state=17, n_jobs=-1)\n", "skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=17)\n", "clf_lr = GridSearchCV(LR, parameters, scoring='roc_auc', cv=skf)\n", "clf_lr.fit(data_woe, data['y'])\n", "print('Best parameters: ', clf_lr.best_params_)\n", "print('ROC_AUC score: ', round(clf_lr.best_score_, 4))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Теперь посмотрим качество на **отложенной выборке** и заодно оценим время работы **Logit** с наилучшими параметрами:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "start = time.time()\n", "LR_control = LogisticRegression(C=0.04, penalty='l1')\n", "LR_control.fit(df, y)\n", "print('ROC_AUC on Control data: ', round(roc_auc_score(y_test, LR_control.predict_proba(df_test)[:,1]), 4))\n", "print('Overall time: ', round(time.time()-start, 1))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 8.2 Случайный лес" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Теперь попробуем **RandomForest**:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "parameters = {'n_estimators':[280, 290, 300, 310, 320, 330], 'max_depth':[8, 9, 10, 11, 12, 15], 'min_samples_leaf': [1, 10, 30, 50, 100]}\n", "RFC = RandomForestClassifier(random_state=17, n_jobs=-1)\n", "skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=17)\n", "clf_rfc = GridSearchCV(RFC, parameters, scoring='roc_auc', cv=skf)\n", "clf_rfc.fit(data_woe, data['y'])\n", "print('Best parameters: ', clf_rfc.best_params_)\n", "print('ROC_AUC score: ', round(clf_rfc.best_score_, 4))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Результат на **отложенной выборке** с наилучшими параметрами:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "start = time.time()\n", "RF_control = RandomForestClassifier(max_depth=9, n_estimators=290)\n", "RF_control.fit(df, y)\n", "print('ROC_AUC on Control data: ', round(roc_auc_score(y_test, RF_control.predict_proba(df_test)[:,1]), 4))\n", "print(\"Overall time:\", round(time.time()-start, 1))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как мы видим, на отложенной выборке себя показывает **RandomForest** с **максимальной глубиной = 9** и **Количеством деревьев = 290**. Для сравнения, посмотрим результат работы **XGBoost**:" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 8.3 XGBoost" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "parameters = {'n_estimators':[40, 50, 60, 100, 200, 300], 'max_depth':[2, 3, 4, 5, 6]}\n", "xgb = XGBClassifier(random_state=17, n_jobs=-1)\n", "skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=17)\n", "clf = GridSearchCV(xgb, parameters, scoring='roc_auc', cv=skf)\n", "clf.fit(data_woe, data['y'])\n", "print('Best parameters: ', clf.best_params_)\n", "print('ROC_AUC score: ', round(clf.best_score_, 4))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "start = time.time()\n", "xgb_control = XGBClassifier(max_depth=4, n_estimators=60, random_state=17, n_jobs=-1)\n", "xgb_control.fit(df, y)\n", "print('ROC_AUC on Control data: ', round(roc_auc_score(y_test, xgb_control.predict_proba(df_test)[:,1]), 4))\n", "print(\"Overall time:\", round(time.time()-start, 1))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как видим, все три алгоритма показали близкие результаты. **XGBoost** и **Random Forest** чуть впереди, но **Логит** показал достойные результаты." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 9. Создание новых признаков" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "В случае данного датасета, большинство признаков, которые логично создать, не имеет смысла вводить в модель, т.к. зависимости, которые они иллюстрируют должны хорошо выделяться случайным лесом и без создания новых признаков. Однако, есть признак, который хочется добавить в модель. У нас есть признак **pdays** (количество дней, прошедшее с предыдущего контакта) и **previous** (количество контактов с данным клиенто до текущей компании). В данном случае, количество контактов с клиентом разделим на количество дней с последнего контакта. Логика в том, что чем больше времени прошло, тем менее важно, как часто ранее общались с клиентом. Назовем это признак **previous_with_memory**." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def previous_with_memory(x):\n", " if x[0] == 0:\n", " return 0\n", " else:\n", " return x[1]/x[0]" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "data_new = data_woe.copy()\n", "data_new['previous_with_memory'] = raw_data[['pdays', 'previous']].apply(previous_with_memory, axis=1)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df, df_test, y, y_test = train_test_split(data_new, data['y'], test_size=0.3, stratify=data['y'], random_state=17)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "start = time.time()\n", "RF_control = RandomForestClassifier(max_depth=9, n_estimators=290, random_state=17, n_jobs=-1)\n", "RF_control.fit(df, y)\n", "print('ROC_AUC on Control data: ', round(roc_auc_score(y_test, RF_control.predict_proba(df_test)[:,1]), 4))\n", "print(\"Overall time:\", round(time.time()-start, 1))\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Для сравнения, запустим **XGBoost** на новых признаках:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "start = time.time()\n", "xgb_control = XGBClassifier(max_depth=4, n_estimators=60, random_state=17, n_jobs=-1)\n", "xgb_control.fit(df, y)\n", "print('ROC_AUC on Control data: ', round(roc_auc_score(y_test, RF_control.predict_proba(df_test)[:,1]), 4))\n", "print(\"Overall time:\", round(time.time()-start, 1))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Как мы видим, прироста мы не увидели, поэтому не будем его использовать. Помимо данного признака, вариантов для создания признаков я не вижу." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Ансамбль \n", "Попробуем объединить алгоритмы **Random Forest** и **XGBoost** в ансамбль" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.ensemble import VotingClassifier\n", "clf1 = LogisticRegression(C=0.04, penalty='l1', random_state=17, n_jobs=-1)\n", "clf2 = RandomForestClassifier(max_depth=9, n_estimators=290, random_state=17, n_jobs=-1)\n", "clf3 = XGBClassifier(max_depth=4, n_estimators=60, random_state=17, n_jobs=-1)\n", "\n", "eclf = VotingClassifier(estimators=[('lr', clf1), ('rf', clf2), ('xgb', clf3)], voting='soft')\n", "skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=17)\n", "\n", "for clf, label in zip([clf2, clf3, eclf], ['Random Forest', 'XGBoost', 'Ensemble']):\n", " scores = cross_val_score(clf, data_woe, data['y'], cv=skf, scoring='roc_auc', n_jobs=-1)\n", " print(\"ROC-AUC: %0.4f (+/- %0.4f) [%s]\" % (scores.mean(), scores.std(), label))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Объединение не дало прирост качества и незначительно увеличило устойчивость алгоритма." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 10. Кривые валидации и обучения \n", "Кривые буду строить для **Random Forest**, т.к. сделал на него основную ставку. В целом он незначительно уступил XGBoost" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "plt.figure(figsize=(8, 8))\n", "param_range = [2, 5, 10, 50, 100, 150, 200, 250, 300, 350, 400]\n", "skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=17)\n", "train_scores, test_scores = validation_curve(\n", " RandomForestClassifier(max_depth=9, random_state=17), data_woe, data['y'], param_name='n_estimators', param_range=param_range,\n", " cv=skf, scoring=\"roc_auc\", n_jobs=-1)\n", "\n", "train_scores_mean = np.mean(train_scores, axis=1)\n", "train_scores_std = np.std(train_scores, axis=1)\n", "test_scores_mean = np.mean(test_scores, axis=1)\n", "test_scores_std = np.std(test_scores, axis=1)\n", "\n", "plt.title(\"Validation Curve with Random Forest\")\n", "plt.xlabel(\"n_estimators\")\n", "plt.ylabel(\"Score\")\n", "plt.ylim(0.77, 0.84)\n", "lw = 2\n", "plt.semilogx(param_range, train_scores_mean, label=\"Training score\",\n", " color=\"darkorange\", lw=lw)\n", "plt.fill_between(param_range, train_scores_mean - train_scores_std,\n", " train_scores_mean + train_scores_std, alpha=0.2,\n", " color=\"darkorange\", lw=lw)\n", "plt.semilogx(param_range, test_scores_mean, label=\"Cross-validation score\",\n", " color=\"navy\", lw=lw)\n", "plt.fill_between(param_range, test_scores_mean - test_scores_std,\n", " test_scores_mean + test_scores_std, alpha=0.2,\n", " color=\"navy\", lw=lw)\n", "plt.legend(loc=\"best\")\n", "plt.grid(color='grey', linestyle='-', linewidth=0.2)\n", "plt.show()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from sklearn.model_selection import learning_curve\n", "plt.figure(figsize=(8, 8))\n", "\n", "plt.title('Learning Curves for Random Forest')\n", "plt.xlabel(\"Training examples\")\n", "plt.ylabel(\"Score\")\n", "skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=17)\n", "train_sizes, train_scores, test_scores = learning_curve(RandomForestClassifier(max_depth=9, n_estimators=290, random_state=17), data_woe, data['y'], cv=skf, n_jobs=1, train_sizes=np.linspace(.1, 1.0, 10), scoring='roc_auc')\n", "train_scores_mean = np.mean(train_scores, axis=1)\n", "train_scores_std = np.std(train_scores, axis=1)\n", "test_scores_mean = np.mean(test_scores, axis=1)\n", "test_scores_std = np.std(test_scores, axis=1)\n", "plt.grid()\n", "\n", "plt.fill_between(train_sizes, train_scores_mean - train_scores_std, train_scores_mean + train_scores_std, alpha=0.1, color=\"r\")\n", "plt.fill_between(train_sizes, test_scores_mean - test_scores_std, test_scores_mean + test_scores_std, alpha=0.1, color=\"g\")\n", "plt.plot(train_sizes, train_scores_mean, 'o-', color=\"r\", label=\"Training score\")\n", "plt.plot(train_sizes, test_scores_mean, 'o-', color=\"g\", label=\"Cross-validation score\")\n", "plt.legend(loc=\"best\")\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Судя по кривой обучения, в целом, данных достаточно, но, видимо, некоторую прибавку качества за счёт увеличения датасета всё же можно было получить. \n", "Из кривой валидации видно, что основной рост качества происходит до 100 деревьев, далее качество меняется незначительно как в плюс так и в минус." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Итог" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Алгоритмы основанные на ансамблях деревьев показали себя очень хорошо, что не удивительно, учитывая, что мы считали WOE (т.е. создали разбиения, получив категориальные признаки для которых на значениях задана операция сравнения), данные не разреженные, размер датасета не маленький. \n", "При этом логистическая регрессия хоть и уступает, но показывает себя достаточно неплохо, т.е. при серьезной работе с ней, можно получать неплохие результаты, при этом интерпретируемые, что является большим плюсом для некоторых бизнес-задач." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Олег Акимов \n", "Slack: @Oleg \n", "LinkedIn: www.linkedin.com/in/oleg-akimov-715532102/ " ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.6.3" } }, "nbformat": 4, "nbformat_minor": 2 }