Коэффициент оттока или просто отток представляет собой коэффициент уклонения клиентов. Для таких сервисов, как 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)
Вывод
Здесь мы видим важность тестирования многих моделей машинного обучения, чтобы найти ту, которая лучше соответствует тому, что вы хотите, и имеющемуся у вас набору данных. Также важно получить наилучшие параметры для моделей, которые это позволяют. Таким образом, вы получите лучшие результаты с вашей машиной.
Мы могли бы немного больше проанализировать, кто является оттоком и, возможно, почему, что позволило бы удовлетворить потребности этих клиентов и избежать оттока, но об этом в другой раз.