
Данная статья рассчитана для новичков в машинном обучении. Используются следующие инструменты:
Python
Random forest classifier
Google Colab
Upsampling data
Каждый дата саентист хоть раз сталкивался с проблемой несбалансированности данных для классификации: какой-то класс превосходит другие. Существует далеко не один способ борьбы с этой проблемой. Наибольшую известность имеет преобразование гиперпараметров, например:
class_weight, но его можно использовать при незначительной несбалансированности: соотношении данных разных классов, например, 4:3 (подробнее можно прочесть тут)
warm_start, который позволяет батчами (частями датасета) обучать данные (подробнее можно прочесть тут)
Однако в данной статье мы рассмотрим метод, не связанный с гиперпараметрами модели: upsampling data. Мы преувеличим количество наименьших классов ещё до обучения модели: продублируем n (отношение количества преобладающего класс к интересующему) раз наименьший класс.
В качестве данных выбраны данные соревнования на kaggle: https://www.kaggle.com/arashnic/banking-loan-prediction. В качестве алгоритма обучения возьмём случайные лес. Начнём!
Импортируем необходимые библиотеки:
import seaborn as sns import pandas as pd import numpy as np import matplotlib.pyplot as plt from numpy import nan from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import confusion_matrix from sklearn.metrics import precision_recall_fscore_support from sklearn.metrics import precision_recall_curve from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score, classification_report, confusion_matrix from sklearn.metrics import roc_auc_score from sklearn.metrics import roc_curve, auc from sklearn import metrics import copy from tune_sklearn import TuneSearchCV import scipy from ray import tune
Загружаем данные (в качестве среды разработки мной использовался Google Colab, а данные располагались на Google Drive):
from google.colab import drive drive.mount('/content/drive') train = pd.read_csv('/content/drive/MyDrive/портфолио/Project "Help to increase customer acquisition"/train.csv') test = pd.read_csv('/content/drive/MyDrive/портфолио/Project "Help to increase customer acquisition"/test.csv')
Посмотрим на исходные данные train (их мы будем использовать для тренировки и теста, в данных test отсутствует таргетированный столбец)
train
Gender | DOB | Lead_Creation_Date | City_Code | City_Category | Employer_Code | Employer_Category1 | Employer_Category2 | Monthly_Income | Customer_Existing_Primary_Bank_Code | Primary_Bank_Type | Contacted | Source | Source_Category | Existing_EMI | Loan_Amount | Loan_Period | Interest_Rate | EMI | Var1 | Approved | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | APPC90493171225 | Female | 23/07/79 | 15/07/16 | C10001 | A | COM0044082 | A | 4.0 | 2000.0 | B001 | P | N | S122 | G | 0.0 | NaN | NaN | NaN | NaN | 0 | 0 |
1 | APPD40611263344 | Male | 07/12/86 | 04/07/16 | C10003 | A | COM0000002 | C | 1.0 | 3500.0 | B002 | P | Y | S122 | G | 0.0 | 20000.0 | 2.0 | 13.25 | 953.0 | 10 | 0 |
2 | APPE70289249423 | Male | 10/12/82 | 19/07/16 | C10125 | C | COM0005267 | C | 4.0 | 2250.0 | B003 | G | Y | S143 | B | 0.0 | 45000.0 | 4.0 | NaN | NaN | 0 | 0 |
3 | APPF80273865537 | Male | 30/01/89 | 09/07/16 | C10477 | C | COM0004143 | A | 4.0 | 3500.0 | B003 | G | Y | S143 | B | 0.0 | 92000.0 | 5.0 | NaN | NaN | 7 | 0 |
4 | APPG60994436641 | Male | 19/04/85 | 20/07/16 | C10002 | A | COM0001781 | A | 4.0 | 10000.0 | B001 | P | Y | S134 | B | 2500.0 | 50000.0 | 2.0 | NaN | NaN | 10 | 0 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
69708 | APPU90955789628 | Female | 31/07/83 | 30/09/16 | C10006 | A | COM0000010 | A | 1.0 | 4900.0 | B002 | P | N | S122 | G | 0.0 | NaN | NaN | NaN | NaN | 10 | 0 |
69709 | APPV80989824738 | Female | 27/01/71 | 30/09/16 | C10116 | C | COM0045789 | A | 4.0 | 7190.1 | B002 | P | N | S122 | G | 1450.0 | NaN | NaN | NaN | NaN | 7 | 0 |
69710 | APPW50697209842 | Female | 01/02/92 | 30/09/16 | C10022 | B | COM0013284 | C | 4.0 | 1600.0 | B030 | P | Y | S122 | G | 0.0 | 24000.0 | 4.0 | 35.50 | 943.0 | 2 | 0 |
69711 | APPY50870035036 | Male | 27/06/78 | 30/09/16 | C10002 | A | COM0000098 | C | 3.0 | 9893.0 | B002 | P | Y | S122 | G | 1366.0 | 80000.0 | 5.0 | NaN | NaN | 10 | 0 |
69712 | APPZ60733046119 | Male | 31/12/89 | 30/09/16 | C10003 | A | COM0000056 | A | 1.0 | 4230.0 | NaN | NaN | Y | S122 | G | 0.0 | 69000.0 | 4.0 | 13.99 | 1885.0 | 10 | 0 |
69713 rows × 22 columns
Как можно заметить, данные необходимо предобработать перед обучением:
создать новые признаки на основе старых: возраст вместо даты рождения, день в году вместо даты заявки на заём (только июль-сентябрь 2016)
обработать nan: заменить nan на моды (категориальные признаки) и медианы (численные признаки)
преобразовать категориальные признаки в числовые (случайный лес обучается на integer, float, boolean)
Для удобства обработки обоих наборов данных создадим функцию предобработки:
def data_preprocessing(df): # преобразуем значения поля Gender: Female - 0, Male - 1 df.loc[(df['Gender'] == 'Female'), 'Gender'] = 0 df.loc[(df['Gender'] != 0), 'Gender'] = 1 # добавим признак возраст df['DOB_year'] = nan df.loc[df['DOB'].notnull(), 'DOB_year'] = 121 - df['DOB'].loc[df['DOB'].notnull()].str[-2:].astype(int) df['DOB_year'] = df['DOB_year'].fillna(df['DOB_year'].median()) # добавим признак дней с начала года от даты заёма (в данных июль-сентябрь 2016) df['Lead_Creation_Date'] = df['Lead_Creation_Date'].str.replace(r'(..\/..\/)(..)', r'\1 20\2') df['Lead_Creation_Date'] = pd.to_datetime(df['Lead_Creation_Date'], format="%d/%m/ %Y") df['Lead_Creation_Date_day'] = (df['Lead_Creation_Date']-pd.to_datetime('1/1/2016')).astype('timedelta64[h]')/24 #удаляем первый символ данных столбцов (они одинаковы), преобразуем в int, #заменяем nan на моду (признак был категориальным) first_drop_cols = ['City_Code', 'Source', 'Customer_Existing_Primary_Bank_Code'] for i in first_drop_cols: df[i] = df[i].loc[df[i].notnull()].str[1:].astype(int) df[i] = df[i].fillna(df[i].mode()[0]) # удаляем первые 3 символа и далее аналогично верхнему df['Employer_Code'] = df['Employer_Code'].loc[df['Employer_Code'].notnull()].str[3:].astype(int) df['Employer_Code'] = df['Employer_Code'].fillna(df['Employer_Code'].mode()[0]) # заполняем nan медианой amount_cols = ['Employer_Category2', 'Monthly_Income', 'Existing_EMI', 'Loan_Amount', 'Loan_Period', 'Interest_Rate', 'EMI', 'Var1'] df[amount_cols] = df[amount_cols].fillna(df[amount_cols].median()) # заполняем nan модой и кодируем столбцы (переводим в численные) str_cols = ['City_Category', 'Employer_Category1', 'Primary_Bank_Type', 'Contacted', 'Source_Category'] str_dict = dict(enumerate(str_cols)) for i in str_cols: df[i] = df[i].fillna(df[i].mode()[0]) le = LabelEncoder() df[str_cols] = df[str_cols].apply(le.fit_transform) return df
train = data_preprocessing(train) test = data_preprocessing(test)
# преобразуем в float not_float_cols = ['ID', 'DOB', 'Lead_Creation_Date'] train[train.columns.difference(not_float_cols)] = train[train.columns.difference(not_float_cols)].astype(float)
#проверим нет ли NaN в столбцах for i in train.columns.difference(unused_cols): print('{} {}'.format(i, train[i].notnull().unique()))

Nan нет, а что же с распределением классов?
# посмотрим на количество строк с разными классами: датасет очень несбалансирован в сторону 0 (в около 68 раз) train['Approved'].value_counts()

# отношение количества строк с 0 к 1 Approved rat = len(train.loc[train['Approved']==0])//len(train.loc[train['Approved']==1]) rat

Создадим новый train датасет методом upsampling:
возьмём все данные с классом 1
продублируем его rat раз
присоединим к данным класса 0 продублированный класс 1 и перемещаем
df_1 = train.loc[train['Approved']==1] df_1 = df_1.loc[df_1.index.repeat(rat)] train_n = pd.concat([train.loc[train['Approved']==0], df_1]).sample(frac=1)
Посмотрим на новое распределение классов:
train_n['Approved'].value_counts()

Приступаем к обучению. Будем использовать случайный лес, а также его тюнинг (подбор наиболее качественных гиперпараметров)
# делим на тренировочную и тестовую X = train_n[train_n.columns.difference(['Approved'])] y = train_n['Approved'] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3) # задаём параметры, из диапазона значений которых надо выбрать лучшее # https://github.com/ray-project/tune-sklearn param_dists = { 'criterion': tune.choice(['gini', 'entropy']), 'max_depth': tune.choice([i for i in range(2, 17)]), 'max_features': tune.choice(['log2', 'sqrt']), 'min_samples_leaf': tune.choice([i for i in range(2, 33)]), 'min_samples_split': tune.choice([i for i in range(2, 17)]), 'random_state': tune.choice([23]) } hyperopt_tune_search = TuneSearchCV(RandomForestClassifier(), param_distributions=param_dists, n_trials=2, early_stopping=True, max_iters=10, search_optimization="hyperopt" ) hts = hyperopt_tune_search.fit(X_train, y_train)
y_pred = hts.predict(X_test) print(confusion_matrix(y_test, y_pred)) print(precision_recall_fscore_support(y_test, y_pred)) print(roc_auc_score(y_test, y_pred, average='weighted'))

Значения метрик f1 получились достаточно высокие (90%+), что может говорить качественности модели классификации.
Таким образом, работать с несбалансированными данными можно не только через гиперпараметры, но и методом upsampling. Он позволяет метрикам наименьшего класса от 0.0 достичь значений более 0.8
