Коэффициент оттока или просто отток представляет собой коэффициент уклонения клиентов. Для таких сервисов, как Spotify и Netflix, это показатель отмены подписи.

Отток чрезвычайно важен для бизнеса в долгосрочной перспективе, поскольку убеждать новых клиентов намного дороже, чем поддерживать текущих клиентов, и зная потенциальных отменяющих клиентов и почему они отменят, бизнес может убедить их в обратном.

Получение данных

Данные получены с Веб-сайта IBM Developer и проект посвящен телекоммуникационным компаниям.

Весь код доступен на Google Colab.

#Import libraries
import pandas as pd
import seaborn as sns
import numpy as np
import matplotlib.pyplot as plt
import scikitplot as skplt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_score
from sklearn.metrics import classification_report
from sklearn.metrics import confusion_matrix
from sklearn.metrics import plot_confusion_matrix
from sklearn.metrics import roc_auc_score, roc_curve, accuracy_score
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
from sklearn.linear_model import SGDClassifier
from xgboost import XGBClassifier
from imblearn.under_sampling import RandomUnderSampler
sns.set_style('dark')
#Import data
DATA_PATH = "https://raw.githubusercontent.com/carlosfab/dsnp2/master/datasets/WA_Fn-UseC_-Telco-Customer-Churn.csv"
df = pd.read_csv(DATA_PATH)

Исследовательский анализ

Словарь переменных

Чтобы сделать анализ более понятным, здесь есть словарь со значением и возможным вводом каждой переменной.

  • custmerID: идентификатор клиента
  • genderCustomer: пол (женский, мужской)
  • SeniorCitizen: является ли клиент пожилым гражданином или нет (1, 0)
  • Partner: у клиента есть партнер или нет (Да, Нет)
  • Dependents: Есть ли у клиента иждивенцы или нет (Да, Нет)
  • tenure: количество месяцев, в течение которых клиент оставался в компании.
  • PhoneService: Есть ли у клиента телефонная связь или нет (Да, Нет)
  • MultipleLines: Есть ли у клиента несколько линий или нет (Да, Нет, Нет телефонной связи)
  • InternetService: интернет-провайдер клиента (DSL, оптоволокно, нет)
  • OnlineSecurity: есть ли у клиента онлайн-защита или нет (да, нет, нет интернет-сервиса)
  • OnlineBackup: есть ли у клиента онлайн-резервное копирование или нет (да, нет, нет интернет-сервиса)
  • DeviceProtection: есть ли у клиента защита устройства или нет (да, нет, нет интернет-сервиса)
  • TechSupport: Есть ли у клиента техническая поддержка или нет (Да, Нет, Нет интернет-сервиса)
  • StreamingTV : Есть ли у клиента потоковое телевидение или нет (да, нет, нет интернет-сервиса)
  • StreamingMovies : Есть ли у клиента потоковое воспроизведение фильмов или нет (Да, Нет, Интернет-услуги отсутствуют)
  • Contract : Срок контракта с клиентом (Месяц за месяц, Один год, Два года)
  • PaperlessBiling : Есть ли у клиента безбумажный биллинг или нет (Да, Нет)
  • PaymentMethod : Способ оплаты клиента (электронный чек, чек по почте, банковский перевод (автоматически), кредитная карта (автоматически))
  • MonthyCharges : Сумма, взимаемая с клиента ежемесячно.
  • TotalCharges : Общая сумма, списанная с клиента.
  • Churn : Ушел ли клиент или нет (да или нет)

Первые записи набора данных позволяют нам понять, как он создается.

df.head

df.shape
(7043, 21)

Набор данных имеет 7043 строки и 21 столбец.

Важным набором в исследовательском анализе является информация (ненулевые записи, типы, имена столбцов и использование памяти).

df.info()
RangeIndex: 7043 entries, 0 to 7042 
Data columns (total 21 columns):  
#   Column            Non-Null Count  Dtype   
---  ------            --------------  -----    
0   customerID        7043 non-null   object   
1   gender            7043 non-null   object   
2   SeniorCitizen     7043 non-null   int64    
3   Partner           7043 non-null   object   
4   Dependents        7043 non-null   object   
5   tenure            7043 non-null   int64    
6   PhoneService      7043 non-null   object   
7   MultipleLines     7043 non-null   object   
8   InternetService   7043 non-null   object   
9   OnlineSecurity    7043 non-null   object   
10  OnlineBackup      7043 non-null   object   
11  DeviceProtection  7043 non-null   object   
12  TechSupport       7043 non-null   object   
13  StreamingTV       7043 non-null   object   
14  StreamingMovies   7043 non-null   object   
15  Contract          7043 non-null   object   
16  PaperlessBilling  7043 non-null   object   
17  PaymentMethod     7043 non-null   object   
18  MonthlyCharges    7043 non-null   float64  
19  TotalCharges      7043 non-null   object   
20  Churn             7043 non-null   object  
dtypes: float64(1), int64(2), object(18) 
memory usage: 1.1+ MB

Здесь мы видим, что нулевых значений нет и что переменная TotalChargesдолжна быть типа float, но имеет тип string.

Мы также можем проверить баланс нашей основной переменной Churn..

No     5174 
Yes    1869 
Name: Churn, dtype: int64  
Churns represent 26.5370% of the dataset.

Для правильной работы модели машинного обучения очень важно, чтобы данные были сбалансированы, поэтому нам придется поработать над этим на этапе построения модели.

Подготовка данных

Чтобы начать подготовку, мы исправим то, что видели на последнем шаге, начиная с изменения TotalCharges type.

df['TotalCharges'] = df['TotalCharges'].apply(pd.to_numeric, errors='coerce')

Теперь, если мы снова проверим информацию, переменная будет числового типа.

RangeIndex: 7043 entries, 0 to 7042 
Data columns (total 21 columns):  
#   Column            Non-Null Count  Dtype   
---  ------            --------------  -----    
0   customerID        7043 non-null   object   
1   gender            7043 non-null   object   
2   SeniorCitizen     7043 non-null   int64    
3   Partner           7043 non-null   object   
4   Dependents        7043 non-null   object   
5   tenure            7043 non-null   int64    
6   PhoneService      7043 non-null   object   
7   MultipleLines     7043 non-null   object   
8   InternetService   7043 non-null   object   
9   OnlineSecurity    7043 non-null   object   
10  OnlineBackup      7043 non-null   object   
11  DeviceProtection  7043 non-null   object   
12  TechSupport       7043 non-null   object   
13  StreamingTV       7043 non-null   object   
14  StreamingMovies   7043 non-null   object   
15  Contract          7043 non-null   object   
16  PaperlessBilling  7043 non-null   object   
17  PaymentMethod     7043 non-null   object   
18  MonthlyCharges    7043 non-null   float64  
19  TotalCharges      7032 non-null   float64   
20  Churn             7043 non-null   object  
dtypes: float64(2), int64(2), object(17) 
memory usage: 1.1+ MB

Но это создало новую проблему, теперь у нас есть пропущенные значения. Так как из 7043 входов было сделано только 11, мы можем просто отказаться от них без больших потерь.

df.dropna(inplace=True)

Еще одна вещь, которая поможет нашему анализу, — это удаление и добавление некоторых столбцов. Например, costumerId — это просто случайное число, которое может помешать нашему анализу, поэтому мы можем просто его отбросить.

df1 = df.drop(columns='customerID', axis=1)

И столбец, который было бы интересно добавить, — это столбец с количеством дополнительных услуг, которые есть у клиента.

df1['AdditonalService'] = (df1[['OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies']] == 'Yes').sum(axis = 1)

И чтобы иметь возможность обучить модель качественным переменным, мы можем заменить «да» на единицы, а «нет» на нули.

df1['Partner'] = df1['Partner'].map({'Yes': 1, 'No': 0})
df1['Dependents'] = df1['Dependents'].map({'Yes': 1, 'No': 0})
df1['PhoneService'] = df1['PhoneService'].map({'Yes': 1, 'No': 0})
df1['PaperlessBilling'] = df1['PaperlessBilling'].map({'Yes': 1, 'No': 0})
df1['Churn'] = df1['Churn'].map({'Yes': 1, 'No': 0})
df1['gender'] = df1['gender'].map({'Male': 1, 'Female': 0})

А для столбцов с большим количеством опций мы можем использовать One Hot Encode, который превращает все уникальные записи в двоичные столбцы.

col_ohe = ['MultipleLines', 'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract', 'PaymentMethod']
df1 = pd.get_dummies(df1, columns=col_ohe)

И вот так выглядит наш набор данных.

Int64Index: 7032 entries, 0 to 7042 Data columns (total 42 columns):  #   Column                                   Non-Null Count  Dtype   ---  ------                                   --------------  -----    0   gender                                   7032 non-null   int64    1   SeniorCitizen                            7032 non-null   int64    2   Partner                                  7032 non-null   int64    3   Dependents                               7032 non-null   int64    4   tenure                                   7032 non-null   int64    5   PhoneService                             7032 non-null   int64    6   PaperlessBilling                         7032 non-null   int64    7   MonthlyCharges                           7032 non-null   float64  8   TotalCharges                             7032 non-null   float64  9   Churn                                    7032 non-null   int64    10  AdditonalService                         7032 non-null   int64    11  MultipleLines_No                         7032 non-null   uint8    12  MultipleLines_No phone service           7032 non-null   uint8    13  MultipleLines_Yes                        7032 non-null   uint8    14  InternetService_DSL                      7032 non-null   uint8    15  InternetService_Fiber optic              7032 non-null   uint8    16  InternetService_No                       7032 non-null   uint8    17  OnlineSecurity_No                        7032 non-null   uint8    18  OnlineSecurity_No internet service       7032 non-null   uint8    19  OnlineSecurity_Yes                       7032 non-null   uint8    20  OnlineBackup_No                          7032 non-null   uint8    21  OnlineBackup_No internet service         7032 non-null   uint8    22  OnlineBackup_Yes                         7032 non-null   uint8    23  DeviceProtection_No                      7032 non-null   uint8    24  DeviceProtection_No internet service     7032 non-null   uint8    25  DeviceProtection_Yes                     7032 non-null   uint8    26  TechSupport_No                           7032 non-null   uint8    27  TechSupport_No internet service          7032 non-null   uint8    28  TechSupport_Yes                          7032 non-null   uint8    29  StreamingTV_No                           7032 non-null   uint8    30  StreamingTV_No internet service          7032 non-null   uint8    31  StreamingTV_Yes                          7032 non-null   uint8    32  StreamingMovies_No                       7032 non-null   uint8    33  StreamingMovies_No internet service      7032 non-null   uint8    34  StreamingMovies_Yes                      7032 non-null   uint8    35  Contract_Month-to-month                  7032 non-null   uint8    36  Contract_One year                        7032 non-null   uint8    37  Contract_Two year                        7032 non-null   uint8    38  PaymentMethod_Bank transfer (automatic)  7032 non-null   uint8    39  PaymentMethod_Credit card (automatic)    7032 non-null   uint8    40  PaymentMethod_Electronic check           7032 non-null   uint8    41  PaymentMethod_Mailed check               7032 non-null   uint8   dtypes: float64(2), int64(9), uint8(31) memory usage: 872.1 KB

Построение модели

Первым шагом является разделение данных на тестовые и обучающие.

X = df1.drop('Churn', axis=1)
y = df1['Churn']
X_train, X_test, y_train, y_test = train_test_split(X, y)

Мы будем использовать перекрестную проверку для оценки базовой ошибки и ошибок исходных моделей. Используемая метрика будет recall и создаст функцию val_model.

def val_model(X, y, clf, quite=False):
  X = np.array(X)
  y = np.array(y)
  pipeline = make_pipeline(StandardScaler(), clf)
  scores = cross_val_score(pipeline, X, y, scoring='recall')
  if quite == False:
    print("Recall: {:.2f} (+/- {:.2f})".format(scores.mean(), scores.std()))
  return scores.mean()

Теперь мы можем сбалансировать данные. Я предпочитаю использовать Under Sampling.

scaler = StandardScaler().fit(X_train)
X_train = scaler.transform(X_train)
X_test = scaler.transform(X_test)
rus = RandomUnderSampler()
X_rus, y_rus = rus.fit_sample(X_train, y_train)
#Check Balance
print(pd.Series(y_rus).value_counts())
#Plot New Distribution
sns.countplot(y_rus);

1    1402 
0    1402 
dtype: int64

Сбалансировав данные, мы можем начать обучение модели. Мы будем использовать перекрестную проверку для проверки производительности многих моделей. Выбранные модели:

  • Логистическая регрессия
  • Древо решений
  • K Ближайший сосед
  • Случайный лес
  • Классификация опорных векторов (SVC)
  • Стохастический градиентный спуск (SGD)
  • XGBoost
#Models
lr = LogisticRegression()
dt = DecisionTreeClassifier()
kn = KNeighborsClassifier()
rf = RandomForestClassifier()
svc = SVC()
sgdc = SGDClassifier()
xgb = XGBClassifier()
model = []
recall = []
#Performance
for clf in (lr, dt, kn, rf, svc, sgdc, xgb):
  model.append(clf.__class__.__name__)
  recall.append(val_model(X_rus, y_rus, clf, quite=True))
pd.DataFrame(data=recall, index=model, columns=['Recall'])

Логистическая регрессия

log_model = LogisticRegression()
log_model.fit(X_rus, y_rus)
y_pred_log = log_model.predict(X_test)
y_proba_log = log_model.predict_proba(X_test)

Древо решений

tree_model = DecisionTreeClassifier(max_depth=10, criterion="entropy")
tree_model.fit(X_rus, y_rus)
y_pred_tree = tree_model.predict(X_test)

K Ближайший сосед

kn_model = KNeighborsClassifier()
kn_model.fit(X_rus, y_rus)
y_pred_kn = kn_model.predict(X_test)
y_proba_kn = kn_model.predict_proba(X_test)

Случайный лес

rf_model = RandomForestClassifier()
rf_model.fit(X_rus, y_rus)
y_pred_rf = rf_model.predict(X_test)
y_proba_rf = rf_model.predict_proba(X_test)

Классификация опорных векторов (SVC)

svc_model = SVC()
svc_model.fit(X_rus, y_rus)
y_pred_svc = svc_model.predict(X_test)

Стохастический градиентный спуск (SGD)

sgd_model = SGDClassifier()
sgd_model.fit(X_rus, y_rus)
y_pred_sgd = sgd_model.predict(X_test)

XGBoost

xgb_model = XGBClassifier()
xgb_model.fit(X_rus, y_rus)
y_pred_xgb = xgb_model.predict(X_test)

В этом сценарии не имеет большого значения, есть ли у модели какие-то ложноотрицательные результаты, поскольку не проблема отправить пару электронных писем и предложений, чтобы убедить клиента, который никогда не собирался уходить.

Имея это в виду, наиболее эффективными моделями являются логистическая регрессия и XGBoost. Я выбираю XGBoost, потому что после небольшой настройки он может работать лучше, чем логистическая регрессия, и это следующий шаг.

Настройка XGBoost

Чтобы сделать XGBoost более точным, мы можем настроить параметры. Мы начнем с learning_rate=0.1 и настроим его в конце.

xgb = XGBClassifier(learning_rate=0.1)
param_grid = {
  'n_estimators': range(0,1000,50),
}
kfold = StratifiedKFold(n_splits=10, shuffle=True)
grid_search = GridSearchCV(xgb, param_grid, scoring="recall", n_jobs=-1, cv=kfold)
grid_result = grid_search.fit(X_rus, y_rus)
print("Best: {} for {}".format(grid_result.best_score_, grid_result.best_params_))

Лучшее количество оценщиков — 50, теперь мы увидим max_depth и min_child_weight.

xgb = XGBClassifier(learning_rate=0.1, n_estimators=50)
param_grid = {
  'max_depth':range(1,8,1),
  'min_child_weight':range(1,5,1)
}
kfold = StratifiedKFold(n_splits=10, shuffle=True)
grid_search = GridSearchCV(xgb, param_grid, scoring="recall", n_jobs=-1, cv=kfold)
grid_result = grid_search.fit(X_rus, y_rus)
print("Best: {} for {}".format(grid_result.best_score_, grid_result.best_params_))

Полученные числа max_depth=1 и min_child_weight=1 теперь мы увидим gamma.

xgb = XGBClassifier(learning_rate=0.1, n_estimators=50, max_depth= 1, min_child_weight= 1)
param_grid = {
  'gamma':[i/10.0 for i in range(0,5)]
}
kfold = StratifiedKFold(n_splits=10, shuffle=True)
grid_search = GridSearchCV(xgb, param_grid, scoring="recall", n_jobs=-1, cv=kfold)
grid_result = grid_search.fit(X_rus, y_rus)
print("Best: {} for {}".format(grid_result.best_score_, grid_result.best_params_))

С gama=0 вернемся к learning_rate.

xgb = XGBClassifier(learning_rate=0.1, n_estimators=50, max_depth= 1, min_child_weight= 1, gamma=0.0)
param_grid = {
  'learning_rate':[0.001, 0.01, 0.1, 1]
}
kfold = StratifiedKFold(n_splits=10, shuffle=True)
grid_search = GridSearchCV(xgb, param_grid, scoring="recall", n_jobs=-1, cv=kfold)
grid_result = grid_search.fit(X_rus, y_rus)
print("Best: {} for {}".format(grid_result.best_score_, grid_result.best_params_))

Наконец, у нас есть learning_rate=0.001. Теперь давайте вернемся к тренировке и тесту и посмотрим, улучшила ли настройка результаты.

xgb_model = XGBClassifier(learning_rate=0.001 , n_estimators=50, max_depth=1, min_child_weight=1, gamma=0.0)
xgb_model.fit(X_rus, y_rus)
y_pred_xgb = xgb_model.predict(X_test)

Вывод

Здесь мы видим важность тестирования многих моделей машинного обучения, чтобы найти ту, которая лучше соответствует тому, что вы хотите, и имеющемуся у вас набору данных. Также важно получить наилучшие параметры для моделей, которые это позволяют. Таким образом, вы получите лучшие результаты с вашей машиной.

Мы могли бы немного больше проанализировать, кто является оттоком и, возможно, почему, что позволило бы удовлетворить потребности этих клиентов и избежать оттока, но об этом в другой раз.