В этом сообщении блога я проведу вас через весь процесс машинного обучения от начала до конца, сосредоточившись в первую очередь на создании классификатора, чтобы предсказать, будут ли клиенты SiriTel уходить, основываясь на ряде различных функций. В этом сообщении в блоге будут рассмотрены некоторые шаги, которые я предпринял для выполнения EDA с неизвестным набором данных, и, в конечном итоге, пройден ряд моделей, чтобы прийти к оптимальному классификатору.
Для начала загрузите и импортируйте необходимые библиотеки.
# import libraries import numpy as np import matplotlib.pyplot as plt %matplotlib inline import pandas as pd import seaborn as sns from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV from sklearn.preprocessing import OneHotEncoder, LabelEncoder, StandardScaler from imblearn.over_sampling import SMOTE from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score, roc_curve, auc from sklearn import tree from sklearn.tree import DecisionTreeClassifier from sklearn.neighbors import KNeighborsClassifier from sklearn.ensemble import RandomForestClassifier
Постройте ящичные диаграммы числовых функций, чтобы получить представление о распределениях
# plot boxplots plt.figure(figsize=(30, 10)) df.boxplot() plt.tight_layout() plt.title('Boxplots of Numeric Columns') plt.xlabel('Numeric Features') plt.show()
Создавайте графики категориальных данных, чтобы получить представление о распределениях. Пример графика для состояния представлен ниже:
plt.figure(figsize=(15, 5)) sns.countplot(df['state'], color='red') plt.tight_layout() plt.title('State Counts') plt.show()
Оценка графиков подсчета различных категориальных столбцов может дать представление о различных значениях в столбцах и выделить любую дополнительную предварительную обработку, которая может потребоваться.
Еще одним полезным инструментом является изучение кросс-таблиц, чтобы увидеть, как категориальные признаки могут быть связаны с нашей целевой переменной. В данном случае это отток.
state_crosstab = pd.crosstab(df['state'], df['churn'], normalize='index') state_crosstab
Как показано выше, создание кросс-таблицы может помочь выявить различия в показателях оттока между разными штатами. Сортировка и отображение столбца True на гистограмме может помочь определить, какие состояния связаны с более высокими уровнями оттока, а какие — с более низкими уровнями оттока. Приведенный ниже код дополнительно выделяет те состояния, в которых скорость оттока больше или равна 20%.
# sort the plot by churn rate to get a better sense of which states are associated with higher churn sorted_state_churn = state_crosstab[1] sorted_state_churn = sorted_state_churn.reset_index() sorted_state_churn.columns = ['state', 'churn_rate'] # sort values based on churn rate sorted_state_churn.sort_values('churn_rate', inplace=True) # plot and highlight states with churn rates higher than 20% plt.figure(figsize=(15, 5)) ax = sns.barplot(sorted_state_churn['state'], sorted_state_churn['churn_rate'], color='red') plt.title("Sorted Churn Rate by State") for bar in ax.patches: if bar.get_height() >= 0.20: bar.set_color('red') else: bar.set_color('grey') plt.show()
Дополнительный код кросс-таблицы используется для создания взаимосвязей между другими категориальными столбцами, например, международным планом, как показано ниже:
# International Plan int_plan_crosstab = pd.crosstab(df['international plan'], df['churn']) normalized_int_plan_crosstab = pd.crosstab(df['international plan'], df['churn'], normalize='index') print('International Plan Counts:') display(int_plan_crosstab) print('--------------------------------') print('International Plan %:') display(normalized_int_plan_crosstab)
Глядя на нормализованные значения, можно получить представление о процентной разбивке.
После того, как потенциальные отношения между нашими целевыми и категориальными столбцами признаков изучены, мы можем перейти к построению блок-диаграмм, чтобы помочь изучить взаимосвязь с нашими непрерывными / числовыми признаками.
# plot boxplots fig, axes = plt.subplots(nrows=8, ncols=2, figsize=(15, 15)) for ax, feat in zip(axes.flatten(), num_cols): sns.boxplot(x=feat, y=df['churn'].astype('category'), data=df, ax=ax) ax.set_title(f'{feat} vs. churn') plt.tight_layout() plt.show()
Оценивая диаграммы, подобные приведенным выше, мы начинаем понимать различные закономерности, возникающие в нашем наборе данных. Например, из приведенной выше визуализации мы видим, что код города одинаково распределен между теми, кто уходит, и теми, кто этого не делает, но у тех, кто уходит, медианное значение минут, как правило, выше, что означает, что те, кто уходит, могут использовать сервис чаще.
Говоря конкретно о звонках в службу поддержки, мы видим, что среднее число звонков у тех, кто уходит, выше, чем у тех, кто этого не делает. На первый взгляд, это имеет смысл, так как клиенты, недовольные обслуживанием, могут звонить/жаловаться чаще, чем те, кто доволен своим текущим обслуживанием и вряд ли поменяет оператора.
При построении кросс-таблицы данных о звонках в службу поддержки мы видим следующее:
Наблюдается явный скачок после третьего звонка в службу поддержки и, аналогично, после 8-го звонка в службу поддержки: 100% клиентов, сделавших 9 обращений в службу поддержки клиентов, отбрасывают друг друга.
Затем создайте тепловую карту корреляций, чтобы увидеть, какие функции связаны
# plot a heatmap of correlations corr = df.corr() plt.figure(figsize=(15,10)) sns.heatmap(corr, cmap='RdBu_r', annot=False, vmax=1, vmin=-1) plt.show()
Оценивая тепловую карту, мы получаем представление о том, какие функции коррелируют с оттоком, нашей целевой переменной. Кроме того, мы видим, что переменные «затраты» и «минуты» идеально коррелируют друг с другом, и обе они не понадобятся в дальнейшем. В дополнение к изучению существующих переменных я разработал ряд функций, помогающих фиксировать общее среднее время разговоров (как международных, так и немеждународных) и общее использование (как международных, так и немеждународных).
# creation of new variables df['all_non_intl_calls'] = df['total day calls'] + df['total eve calls'] + df['total night calls'] df['all_calls'] = df['all_non_intl_calls'] + df['total intl calls'] df['all_non_intl_mins'] = df['total day minutes'] + df['total eve minutes'] + df['total night minutes'] df['all_mins'] = df['all_non_intl_mins'] + df['total intl minutes'] df['avg_non_intl_call_time'] = df['all_non_intl_mins'] / df['all_non_intl_calls'] df['avg_call_time'] = df['all_mins'] / df['all_calls']
Теперь, когда у нас есть четкое представление о наших данных, мы можем перейти к подготовке данных и моделированию.
Для начала, используя информацию, найденную в EDA выше, я выбираю подмножество функций, которые могут повлиять на отток.
predictors = ['state', 'account length', 'international plan', 'voice mail plan', 'number vmail messages', 'total day minutes', 'total eve minutes', 'total night minutes', 'total intl minutes', 'customer service calls', 'all_non_intl_calls', 'all_calls', 'all_non_intl_mins', 'all_mins', 'avg_non_intl_call_time', 'avg_call_time']
Затем настройте обучающие и тестовые наборы.
X = clean_df[predictors] y = clean_df['churn'] final_clean_df = pd.concat([X, y], axis=1) # create train and test sets, stratify to keep proportions X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=SEED, stratify=y)
Здесь stratify=y
настроен на сохранение баланса классов одинаковым. В нашем предсказателе есть некоторый дисбаланс классов (отток), который вскоре будет обработан с помощью SMOTE. Прежде чем создавать синтетические данные с smote для обработки дисбаланса классов, нам нужно выполнить однократное кодирование и кодирование меток для наших различных категориальных столбцов.
# encode columns # state ohe = OneHotEncoder(categories='auto', handle_unknown='ignore', sparse=False) X_train_state_encoded = ohe.fit_transform(X_train[['state']]) X_train_state_encoded = pd.DataFrame(X_train_state_encoded, columns=ohe.categories_[0], index=X_train.index) X_test_state_encoded = ohe.transform(X_test[['state']]) X_test_state_encoded = pd.DataFrame(X_test_state_encoded, columns=ohe.categories_[0], index=X_test.index) # drop initial state column from both test and train data, and concat with remaining features X_train = X_train.drop('state', axis=1) X_test = X_test.drop('state', axis=1) X_train_ohe = pd.concat([X_train, X_train_state_encoded], axis=1) X_test_ohe = pd.concat([X_test, X_test_state_encoded], axis=1) # label encode voice mail plan and international plan le = LabelEncoder() # encode international plan X_train_ohe['intl_plan_encoded'] = le.fit_transform(X_train_ohe['international plan']) X_test_ohe['intl_plan_encoded'] = le.transform(X_test_ohe['international plan']) # encode voicemail plan X_train_ohe['vm_plan_encoded'] = le.fit_transform(X_train_ohe['voice mail plan']) X_test_ohe['vm_plan_encoded'] = le.transform(X_test_ohe['voice mail plan']) # drop original columns X_train_encoded = X_train_ohe.drop(['international plan', 'voice mail plan'], axis=1) X_test_encoded = X_test_ohe.drop(['international plan', 'voice mail plan'], axis=1) X_train_final = X_train_encoded.copy() X_test_final = X_test_encoded.copy()
Теперь, когда мы закодировали наши данные, мы можем перейти к обработке дисбаланса классов. Во-первых, изучите текущий дисбаланс классов.
# print initial target class weights print('Initial Class Weights of Target:') print(y_train.value_counts()) print(y_train.value_counts(normalize=True))
Существует явный дисбаланс: 85,5% нашей целевой переменной помечены как 0 или не взбалтываются. В результате простая модель, в которой каждый клиент помечается как не ушедший, по-прежнему будет иметь точность ~85,5%. Мы будем помнить об этом, когда приступим к моделированию. Чтобы справиться с дисбалансом классов, синтетические обучающие данные будут генерироваться с помощью SMOTE.
# create synthetic training data using SMOTE to address imbalance X_train_resampled, y_train_resampled = SMOTE(random_state=SEED).fit_resample(X_train_final, y_train) X_train_resampled = pd.DataFrame(X_train_resampled, columns=X_train_final.columns) # print new class weights print('Balanced Class Weights of Target:') print(pd.Series(y_train_resampled).value_counts()) print(pd.Series(y_train_resampled).value_counts(normalize=True))
Перед моделированием важно решить, какие показатели производительности будут использоваться для оценки различных опробованных моделей. В этом сценарии я решил сосредоточиться на счете F1, так как показатель f1 не может быть высоким, если Точность и Отзыв также не будут высокими. Кроме того, высокая оценка отзыва, вероятно, является наиболее интуитивно понятной и полезной в этом бизнес-контексте, поскольку она позволит различным отделам продаж и специалистам службы поддержки клиентов SiriTel проводить целенаправленную работу с клиентами / рекламные акции. Кроме того, информация, полученная в результате этого процесса, может быть полезна для SiriTel при разработке различных пороговых показателей, которые будут использоваться внутренними командами для отслеживания вероятности оттока.
Я настроил функцию для обработки всех оценок и оценок модели:
def print_model_scores(X_train, X_test, y_train, y_test, model, model_name): """ Function to return accuracy, recall, precision, f1, roc_auc, and neg_log_loss from given X_train, X_test, y_train, y_test, and fit model. """ # create predictions using model y_test_preds = model.predict(X_test) y_train_preds = model.predict(X_train) # accuracy scores train_accuracy = accuracy_score(y_train, y_train_preds) test_accuracy = accuracy_score(y_test, y_test_preds) # precision score train_precision = precision_score(y_train, y_train_preds) test_precision = precision_score(y_test, y_test_preds) # recall score train_recall = recall_score(y_train, y_train_preds) test_recall = recall_score(y_test, y_test_preds) # f1 score train_f1 = f1_score(y_train, y_train_preds) test_f1 = f1_score(y_test, y_test_preds) # roc_auc false_positive_rate, true_positive_rate, thresholds = roc_curve(y_test, y_test_preds) test_roc_auc = auc(false_positive_rate, true_positive_rate) fpr, tpr, thresh = roc_curve(y_train, y_train_preds) train_roc_auc = auc(fpr, tpr) print('Accuracy:') print(f'Training Set: {train_accuracy}') print(f'Test Set: {test_accuracy}') print('---------------------------------') print('Precision:') print(f'Training Set: {train_precision}') print(f'Test Set: {test_precision}') print('---------------------------------') print('Recall:') print(f'Training Set: {train_recall}') print(f'Test Set: {test_recall}') print('---------------------------------') print('F1 Score:') print(f'Training Set: {train_f1}') print(f'Test Set: {test_f1}') print('---------------------------------') print(f'ROC AUC:') print(f'Training Set: {train_roc_auc}') print(f'Test Set: {test_roc_auc}') test_results = pd.DataFrame([[f'Test-{model_name}', test_accuracy, test_precision, test_recall, test_f1, test_roc_auc]], columns=['Model', 'Accuracy', 'Precision', 'Recall', 'F1 Score', 'AUC']) train_results = pd.DataFrame([[f'Training-{model_name}', train_accuracy, train_precision, train_recall, train_f1, train_roc_auc]], columns=['Model', 'Accuracy', 'Precision', 'Recall', 'F1 Score', 'AUC']) # concat results results = pd.concat([test_results, train_results], axis=0) return results
Сбалансировав наши целевые данные для обучения, мы можем двигаться вперед, повторяя ряд моделей. Хотя перед тем, как мы пришли к нашей окончательной модели, использовался ряд моделей, включая деревья решений и случайные леса, для целей этого сообщения в блоге я сосредоточусь на окончательной модели, к которой мы пришли, XG Boost.
Для начала я создал базовый классификатор XGBoost без гиперпараметров.
# run a SVC and see if we can improve scoring from xgboost import XGBClassifier # create baseline classifier baseline_xgb = XGBClassifier(random_state=SEED) # fit baseline_xgb.fit(X_train_resampled, y_train_resampled) # store scores baseline_xgb_results = print_model_scores(X_train_resampled, X_test_final, y_train_resampled, y_test, baseline_xgb, 'baseline_xgb')
Эта базовая модель дала следующие результаты:
Как мы видим, производительность на тестовых наборах относительно высока, но присутствует некоторое переоснащение. Несмотря на это, мы видим, что показатель точности является высоким как для тестовых, так и для обучающих наборов, и превосходит нашу «простую модель», в которой каждый клиент помечен как не отходящий.
Настройка гиперпараметров с помощью gridsearch позволяет нам повысить производительность по сравнению с нашим базовым уровнем.
xgb_param_grid = { 'learning_rate': [0.1, 0.2], 'max_depth': [1, 2, 3], 'subsample': [0.5, 0.7], 'min_child_weight': [2, 3] } # create gridsearch xgb_grid_search = GridSearchCV(baseline_xgb, xgb_param_grid, cv=3, return_train_score=True, scoring='f1') # fit model xgb_grid_search.fit(X_train_resampled, y_train_resampled)
Мы можем вывести лучшие параметры, найденные в gridsearch:
# Mean training score xgb_gs_training_score = np.mean(xgb_grid_search.cv_results_['mean_train_score']) # Mean test score xgb_gs_testing_score = xgb_grid_search.score(X_test_final, y_test) print(f'Mean Training Score: {xgb_gs_training_score}') print(f'Mean Testing Score: {xgb_gs_testing_score}') print("Best Parameter Combination Found During Grid Search:") xgb_grid_search.best_params_
Повторно запуская нашу модель с этими гиперпараметрами, мы получаем:
# train model with these parameters best_xgb = XGBClassifier(random_state=SEED, learning_rate=0.2, max_depth=3, subsample=0.5, min_child_weight=2) # fit to training data best_xgb.fit(X_train_resampled, y_train_resampled) # store results best_xgb_results = print_model_scores(X_train_resampled, X_test_final, y_train_resampled, y_test, best_xgb, 'best_xgb')
Сравнивая наш настроенный классификатор XG Boost с нашим базовым уровнем, наши результаты немного лучше. Отзыв остался прежним, но мы улучшили общую оценку и точность F1, а также немного увеличили точность. Это лучшая модель, которую мы видели до сих пор, и она будет использоваться в качестве нашей окончательной модели. Хотя можно выполнить дополнительную итерацию, наша окончательная модель довольно хорошо работает на тестовых данных. Использование нашей модели для прогнозирования вероятностей позволяет нам составить список клиентов и вероятность, заданную нашей моделью оттока.
all_predictions = final_model.predict(transformed_data) all_probs = final_model.predict_proba(transformed_data)
Использование и предоставление этого списка в SiriaTel позволит им узнать, какие клиенты подвергаются более высокому риску оттока, и позволит им решить эту проблему по своему усмотрению.