Данные лежат здесь: https://yadi.sk/d/mJbzt5pV3Uf5Zt
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_score, TimeSeriesSplit, GridSearchCV
from sklearn.metrics import accuracy_score,classification_report,f1_score,roc_auc_score,roc_curve,precision_recall_curve
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from lightgbm import LGBMClassifier as lgbmc
from catboost import CatBoostClassifier as catc
from xgboost import XGBClassifier as xgbc
plt.rcParams['figure.figsize'] = (20,20)
sns.set(style="darkgrid");
%matplotlib inline
Одной из главных целей для любой компании является удержание своих клиентов. В торговле успехом данного процесса является совершение повторных покупок клиентами в интервал времени, который характеризует потребление различных видов товаров:
В данном проекте будут исследованы данные одного заказчика (менеджеров крупного интернет-гипермаркета, основным ассортиментом которого являются товары повседневного спроса) и построена модель, предсказывающая вероятность оттока клиента.
Заказчик определил отток таким образом: клиент не сделает повторный заказ в течение трёх месяцев. Такая постановка обусловлена тем, что почти 80% клиентов делают свой повторный заказ в течение 3-х месяцев. Таким образом поставлена цель научиться определять 20% клиентов, которые этого не сделают.
После этого, уже можно разрабатывать различные подходы к стимулированию данного пула клиентов к повторной сделке с помощью различных маркетинговых методов.
Данные были получены от заказчика и сведены в один DataFrame. Посмотрим на них.
df = pd.read_csv('data.csv',index_col='Client')
df.head(10)
df.shape
df.info()
for index,value in enumerate(df.columns):
print (index,":",value)
Как видно у нас 273 столбца, целевая переменная - target, 271 - количественный и 1 категориальный признак("Y M" = "Год Месяц"). Каждая строка - описание клиента (история его покупок за текущий и предыдущие 6 месяцев) в месяц последней покупки.
R - Revenue - Выручка от продажи;
S - Strings - Кол-во строк - разных позиций (артикулов);
O - Orders - Кол-во заказов;
Q - Qnt - Кол-во штук;
R_1 ... R_6, R_NOW- Выручка по месяцам. NOW - месяц, соответствующий Y_M, _6 - предыдущий, _1 - 6 месяцев назад.
Month: от 1 до 12 (январь-декабрь).
Y или N в R_Y_NOW, O_Y_NOW, R_N_NOW, R_N_NOW - выручка/заказы в зависимости от способа оформления заказа. Y - через сайт, N - по телефону.
Orders-1003 ... Orders-Other - заказы за 7 месяцев (от _1 до _NOW) по выделенной группе товаров (70 "топовых" групп: 1003, ..., 931) или по остальным (Other). Аналогично и с выручкой и кол-ву штук.
Other - Кол-во групп товаров, купленных за 7 месяцев, входящих в группу "Other".
Посмотрим количество пропусков в данных. Как видно их нет.
sum(df.isnull().sum())
Посмотрим среднее количество "отточных клиентов" в наборе данных.
print ('% клиентов, склонных к оттоку:', round(df['target'].mean()*100,2))
Получается даже меньше 20%. Выборка не сбалансирована.
churn=pd.crosstab(index=df['Y_M'],columns=df['target'])
churn['%']=round(churn[1]/churn[0]*100,2)
churn.T
churn_m=pd.crosstab(index=df['Month'],columns=df['target'])
churn_m['%']=round(churn_m[1]/churn_m[0]*100,2)
churn_m.T
Видна некоторая сезонность, в конце года "отточных" клиентов больше, летом - меньше. Осенью клиенты делают закупки активнее.
Для начала посмотрим на статистичекское описание ежемесячных показателей.
df.drop(columns=['target','Month']).iloc[:,:29].describe().T
df.drop(columns=['target','Month']).iloc[:,29:29+14*2].describe().T
Заметно, что преобладают заказы, оформленные через Интернет.
ch_1=df[df['target']==1].drop(columns=['target','Month']).iloc[:,:29].describe().T
ch_0=df[df['target']==0].drop(columns=['target','Month']).iloc[:,:29].describe().T
ch_0-ch_1
Как видно, клиенты, которые нас интересуют - покупают меньше. В особенности, в предыдущие периоды.
goods=pd.pivot_table(data=df,values=df.iloc[:,202:273],columns=df['target'],aggfunc=np.sum)
goods['%_churn']=goods[1]/goods[0]*100
goods.sort_values(by='%_churn',ascending=False).head(10)
Как видно, по разным категориям товаров доля затраченных денег отличается. Таким образом, если клиент потратил сумму на какую-то группу товаров, то вероятность его ухода как понижается, так и повышается в зависимости от этой группы.
goods_ord=pd.pivot_table(data=df,values=df.iloc[:,60:131],columns=df['target'],aggfunc=np.sum)
goods_ord['%_churn']=goods_ord[1]/goods_ord[0]*100
goods_ord.sort_values(by='%_churn',ascending=False).head(10)
sns.heatmap(np.corrcoef(goods_ord['%_churn'],goods['%_churn']));
Как видно, заказы и деньги по товарным категориям коррелируют.
Other_0=df[df['target']==0]['Other'].describe()
Other_1=df[df['target']==1]['Other'].describe()
Other=pd.concat([Other_0,Other_1],axis=1,names=['Total','1'])
Other.columns=['0','1']
Other
Как видно, уходящие клиенты покупают обычно в два раза меньше товаров из категории "Другое".
Визуализируем распределение целевого класса.
plt.figure(figsize=[8, 5])
sns.countplot(df['target']);
Далее будем исследовать распределения признаков в зависимости от значения "target". Для скошенных влево распределений будем применять log(1+x) преобразование и отсекать экстремально большие значения (>95%-99% квантили).
plt.figure(figsize=[20, 15])
for i in range(1,8):
plt.subplot(3, 3, i)
sns.distplot(np.log1p(df[df['target']==1].iloc[:,i].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(np.log1p(df[df['target']==0].iloc[:,i].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('log1x')
plt.figure(figsize=[20, 15])
for i in range(1,8):
plt.subplot(3, 3, i)
sns.distplot(df[(df['target']==1) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(df[(df['target']==0) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('<95% quantile')
plt.figure(figsize=[20, 15])
for i in range(1+8,8+8):
plt.subplot(3, 3, i-8)
sns.distplot(np.log1p(df[df['target']==1].iloc[:,i].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(np.log1p(df[df['target']==0].iloc[:,i].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('log1x')
plt.figure(figsize=[20, 15])
for i in range(1+8,8+8):
plt.subplot(3, 3, i-8)
sns.distplot(df[(df['target']==1) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i],kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(df[(df['target']==0) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i],kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('<95% quantile')
plt.figure(figsize=[20, 15])
for i in range(1+8+7,8+8+7):
plt.subplot(3, 3, i-8-7)
sns.distplot(np.log1p(df[df['target']==1].iloc[:,i].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(np.log1p(df[df['target']==0].iloc[:,i].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('log1x')
plt.figure(figsize=[20, 15])
for i in range(1+8+7,8+8+7):
plt.subplot(3, 3, i-8-7)
sns.distplot(df[(df['target']==1) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.99))].iloc[:,i],kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(df[(df['target']==0) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.99))].iloc[:,i],kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('<99% quantile')
plt.figure(figsize=[20, 15])
for i in range(1+8+7+7,8+8+7+7):
plt.subplot(3, 3, i-8-7-7)
sns.distplot(np.log1p(df[df['target']==1].iloc[:,i].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(np.log1p(df[df['target']==0].iloc[:,i].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('log1x')
plt.figure(figsize=[20, 15])
for i in range(1+8+7+7,8+8+7+7):
plt.subplot(3, 3, i-8-7-7)
sns.distplot(df[(df['target']==1) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(df[(df['target']==0) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('<95% quantile')
По распределениям показателей за месяца можно увидеть, что:
plt.figure(figsize=[20, 15])
for i in range(1+8+7+7+8,8+8+7+7+8):
plt.subplot(3, 3, i-8-7-7-8)
sns.distplot(df[(df['target']==1) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(df[(df['target']==0) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('<95% quantile')
plt.figure(figsize=[20, 15])
for i in range(1+8+7+7+8+7,8+8+7+7+8+7):
plt.subplot(3, 3, i-8-7-7-8-7)
sns.distplot(df[(df['target']==1) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(df[(df['target']==0) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.95))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('<95% quantile')
plt.figure(figsize=[20, 15])
for i in range(1+8+7+7+8+7+7,8+8+7+7+8+7+7):
plt.subplot(3, 3, i-8-7-7-8-7-7)
sns.distplot(df[(df['target']==1) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.99))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(df[(df['target']==0) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.99))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('<99% quantile')
plt.figure(figsize=[20, 15])
for i in range(1+8+7+7+8+7+7+7,8+8+7+7+8+7+7+7):
plt.subplot(3, 3, i-8-7-7-8-7-7-7)
sns.distplot(df[(df['target']==1) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.99))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='r',label='target: 1')
sns.distplot(df[(df['target']==0) & (df.iloc[:,i]<df.iloc[:,i].quantile(0.99))].iloc[:,i].apply(lambda x: 0 if x<0 else x),kde=False,norm_hist=True,color='g',label='target: 0')
plt.legend()
plt.title('<99% quantile')
По способу оформления заказа клиенты мало отличаются, все предпочитают - Интернет.
sns.factorplot(x='Other',y='target',data=df,kind='bar',size=5,aspect=2.8);
По распределению доли отточных клиентов, видна обратная зависимость от количества товарных групп (ТГ) из "Другое", чем меньше ТГ - тем больше доля оттока.
sns.factorplot(x='Month',y='target',data=df,kind='bar',size=4,aspect=2.2);
Доля оттока зависит от рассматриваемого месяца текущей закупки.
plt.figure(figsize=[35, 35])
sns.heatmap(df.drop(columns=['target','Month']).corr(),cmap="RdBu_r");
Из корреляционной матрицы видно, что есть коррелирующие признаки:
Найдены и выдвинуты предположения о природе различных корреляций/пропусков/закономерностей и выбросов, найденных в предыдущих пунктах. Есть пояснение, почему они важны для решаемой задачи;
По проведённому анализу можно сделать выводы:
Для задач бинарной классификации обычно используются следующие метрики:
В данной задаче целевой класс несбалансирован (85%/15%).Также для применения модели в жизни нужно оценивать вероятность того, что клиент не сделает покупку в следующем промежутке времени, чтобы выбрать оптимальный порог принятия решения на кого воздействовать различными способами. Таким образом, наиболее подходящей метрикой является ROC-AUC.
В качестве моделей будут использоваться:
В принципе, это наиболее часто используемые модели для задач классификаций, где обучающая выборка не очень большая и не сильно разряженная. У каждой модели есть свои плюсы для решаемой задачи:
Проведена предобработка данных для конкретной модели. При необходимости есть и описано масштабирование признаков, заполнение пропусков, замены строк на числа, OheHotEncoding, обработка выбросов, отбор признаков с описанием используемых для этого методов. Корректно сделано разбиение данных на обучающую и отложенную части;
Из категориальных кризнаков у нас только месяц, сделаем OHE. Удалим столбец "Month".
df=pd.concat([df,pd.get_dummies(df['Month'], prefix='M', prefix_sep='_')],axis=1)
df=df.drop(columns='Month')
У нас данные представлены за период июль 2016 - декабрь 2017. Для корректной валидации нужно учитывать временную составляющую. На всякий случай отсортируем данные по столбцу "Y_M" и удалим этот признак.
df=df.sort_values(by='Y_M')
df.head()
df.tail()
df=df.drop(columns='Y_M')
Разобьём всю выборку на обучающую и проверочную в соотношении 9:1. В проверочную часть попадут данные за 2 последних месяца.
idx = int(df.shape[0]*0.9)
df_train = df.iloc[:idx,:]
df_valid = df.iloc[idx:,:]
df.shape,df_train.shape,df_valid.shape
Как уже говорилиось, у нас есть временная зависимость, поэтому для правильной валидации будем использовать sklearn.TimeSeriesSplit. Сделаем 10 фолдов, чтобы валидироваться на выборке соизмеримой с df_valid.
tscv=TimeSeriesSplit(n_splits=10)
Посмотрим на качество моделей без настроек параметров на исходных признаках, для LogisticRegression сделаем стандартизацию признаков.
X_train = df_train.drop(columns='target')
y_train = df_train['target']
lr = LogisticRegression()
rf = RandomForestClassifier()
lg = lgbmc()
xg = xgbc()
cb = catc()
std = StandardScaler()
lr_pipeline = make_pipeline(std,lr)
%%time
lrcv = cross_val_score(lr_pipeline,X_train,y_train,cv=tscv,scoring='roc_auc')
rfcv = cross_val_score(rf,X_train,y_train,cv=tscv,scoring='roc_auc')
lgcv = cross_val_score(lg,X_train,y_train,cv=tscv,scoring='roc_auc')
xgcv = cross_val_score(xg,X_train,y_train,cv=tscv,scoring='roc_auc')
print ('lr_cv_score',np.mean(lrcv),"+-",np.std(lrcv))
print ('rf_cv_score',np.mean(rfcv),"+-",np.std(rfcv))
print ('lg_cv_score',np.mean(lgcv),"+-",np.std(lgcv))
print ('xg_cv_score',np.mean(xgcv),"+-",np.std(xgcv))
На кроссвалидации лучший результат оказался у xgboost, худший - у случайного леса, логистическая регрессия показала результат немного ниже, чем у бустинга.
Попробуем улучшить результат с помощью добавлением новых признаков и убиранием малоинформативных, а также настройкой гиперпараметров.
Создадим признаки: Общее количество заказов за период, количество закупок (в месяцах), количество "топовых" товарных групп за 7 месяцев.
df_train['Orders_total']=df_train.iloc[:,15:22].sum(axis=1)
sns.distplot(np.log1p(df_train[df_train['target']==1]['Orders_total'].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='r');
sns.distplot(np.log1p(df_train[df_train['target']==0]['Orders_total'].apply(lambda x: 0 if x<0 else x)),kde=False,norm_hist=True,color='g');
df_train['B_M']=np.array([df_train.iloc[:,i].apply(lambda x: 1 if x >0 else 0) for i in range(0,6)]).sum(axis=0)
sns.factorplot(y='target',x='B_M',data=df_train,kind='bar');
df_train['TG_total']=np.array([df_train.iloc[:,i].apply(lambda x: 1 if x >0 else 0) for i in range(200,270)]).sum(axis=0)
sns.factorplot(y='target',x='TG_total',data=df_train,kind='bar',aspect=5);
По всем трём признакам видна закономерность - чем меньше значение, тем выше доля оттока. Проверим качество с добавлением этих признаков для xgboost.
X_train = df_train.drop(columns='target')
y_train = df_train['target']
xgcv = cross_val_score(xg,X_train,y_train,cv=tscv,scoring='roc_auc')
print ('xg_cv_score',np.mean(xgcv),"+-",np.std(xgcv))
Качество не улучшилось. Настроим параметры на кросс-валидации.
XG_params = {'n_estimators': [100,200,300,400,500],
'seed':[17],
'max_depth': [3,4,5,6,7,8],
'learning_rate': [0.01,0.05,0.1]}
xggs = GridSearchCV(xg,param_grid=XG_params,scoring='roc_auc',cv=tscv)
xg.fit(X_train,y_train)
df_valid['Orders_total']=df_valid.iloc[:,15:22].sum(axis=1)
df_valid['B_M']=np.array([df_valid.iloc[:,i].apply(lambda x: 1 if x >0 else 0) for i in range(0,6)]).sum(axis=0)
df_valid['TG_total']=np.array([df_valid.iloc[:,i].apply(lambda x: 1 if x >0 else 0) for i in range(200,270)]).sum(axis=0)
X_valid = df_valid.drop(columns='target')
y_valid = df_valid['target']
y_pred_proba=xg.predict_proba(X_valid)[:,1]
roc_auc_score(y_valid,y_pred_proba)
Результат получился выше, чем на валидации. Это связано с тем, что у нас была TimeSeriesValidation.
После исследования и построения модели можно сделать следюущие выводы:
1)В принципе, получена модель с неплохим качеством, которая может помогать определять клиентов, которые не закупятся в ближайшие 3 месяца.
2)Возможные пути улучшения модели - добавить признаки другого типа (взаимодействия с клиентами), покрутить признаки.
3)Логистическая регрессия показала неплохие результаты, можно попробовать немного поднастроить её вместо построения многих деревьев.