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

Данные

Машинному обучению нужны данные. Есть хорошая аналогия того, как машинное обучение будет работать с данными. Когда мы хотели научиться делать что-то, скажем, фотографировать, мы искали «шаблоны» того, как должна выглядеть красивая фотография. Должна ли красочная фотография быть классифицирована как красивая или симметричная фотография должна быть классифицирована как красивая? Машинное обучение также использует этот подход. Ему нужны данные о прошлых событиях, чтобы понять характер клиентов, склонных к дефолту.

Данные, которые я буду использовать сегодня, получены в результате теста при приеме на работу, который я уже проходил ранее для должности в финансовой компании. Первоисточник данных анонимен, но сами данные весьма интересны. Я оставлю ссылку на полный набор данных в конце статьи, если вы, ребята, заинтересованы в том, чтобы попробовать данные самостоятельно. Данные состоят из 4 разных таблиц и под таблицами я имею в виду 4 разных файла .csv. 4 разные таблицы:

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

train = pd.read_csv('app_train.csv')
train.drop('Unnamed: 0',axis = 1,inplace=True)
test = pd.read_csv('app_test.csv')
test.drop('Unnamed: 0',axis = 1,inplace=True)
prev = pd.read_csv('prev_app.csv')
prev.drop('Unnamed: 0',axis = 1,inplace=True)
behavior = pd.read_csv('installment_payment.csv')
behavior.drop('Unnamed: 0',axis = 1,inplace=True)

ПЕРВЫЙ ШАГ (Что делать с файлом installmentpayment.csv)

Набор данных installmentpayment.csv содержит следующие переменные:

Мы удалим LN_ID, так как мы будем присоединяться к таблице с предыдущим набором данных кредита, а не напрямую с набором данных поезда. Интересно в этой таблице то, что мы фактически можем сократить INST_DAYS, PAY_DAYS, AMT_INST и AMT_PAY в 2 новые переменные, которые говорят об одном и том же. Мы можем создать переменную PREV_LATENESS, которая описывает задержку каждого предыдущего платежа. Мы также можем создать другую переменную, которая описывает разницу между предписанной суммой и суммой оплаты. Мы назовем эту переменную PREV_PAY_DEFICIT, чтобы измерить способность клиентов завершить платеж.

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

behavior.drop('LN_ID',axis= 1,inplace=True)
behavior.describe()
behaviorengineered = behavior[:]
behaviorengineered['PREV_PAY_DEFICIT']=behaviorengineered.AMT_INST - behaviorengineered.AMT_PAY
behaviorengineered['PREV_LATENESS'] = behaviorengineered.INST_DAYS - behaviorengineered.PAY_DAYS
behaviorengineered.drop(['INST_DAYS','PAY_DAYS','AMT_INST','AMT_PAY'],axis = 1, inplace=True)
behaviorengineered.fillna(behaviorengineered.median(),inplace=True)

ВТОРОЙ ЭТАП (объединение поведенческих данных с данными о предыдущем кредите)

Мы добавим поведенческие данные, созданные с помощью предыдущих данных о кредите, чтобы описать данные о кредитных заявках предыдущих клиентов и их поведение. Набор данных предыдущей заявки на получение кредита содержит показанную одну строку для каждого уникального SK_ID_PREV, что означает, что каждая строка описывает только одну запись предыдущей заявки. Между тем, набор данных о поведении имеет несколько строк с одним и тем же SK_ID_PREV, что означает, что он показывает действия каждого SK_ID_PREV, которые формируют поведение соответствующего SK_ID_PREV. Таким образом, INST_NUM — это « запись поведения SK_ID_PREV.

— — — — — — — — — —-

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

— — — — — — — — — —-

prevpaydeficit = behaviorengineered.groupby('SK_ID_PREV')['PREV_PAY_DEFICIT'].agg(lambda x:x.median() if x.notnull().any() else np.nan)
prevlateness = behaviorengineered.groupby('SK_ID_PREV')['PREV_LATENESS'].agg(lambda x:x.median() if x.notnull().any() else np.nan)
prev['PREV_PAY_DEFICIT'] = prev['SK_ID_PREV'].apply(lambda x: prevpaydeficit[x] if x in prevpaydeficit.index else np.nan)
prev['PREV_LATENESS'] = prev['SK_ID_PREV'].apply(lambda x:prevlateness[x] if x in prevlateness.index else np.nan)

Количество пропущенных значений, показанное на картинке выше, показывает, что у нас много недостающей информации о предыдущем кредите. Кроме того, почти половина нашего поведения SK_ID_PREV не была описана из-за количества отсутствующих значений столбцов поведения. Мы не будем заботиться о пропущенных значениях, присутствующих сейчас, потому что эти данные действуют только как дополнительные для набора данных поезда. Мы позаботимся об отсутствующих значениях, показанных после того, как объединим все данные в виде набора данных поезда. Кроме того, нам нужно закодировать категориальные переменные в предыдущем наборе данных кредита.

from sklearn.preprocessing import LabelEncoder, OrdinalEncoder
LEcontract_type = LabelEncoder()
LEweekdays_apply = LabelEncoder()
LEcontract_status = LabelEncoder()
LEyield_group = LabelEncoder()
prev['CONTRACT_TYPE'] = LEcontract_type.fit_transform(prev.CONTRACT_TYPE)
prev.WEEKDAYS_APPLY = LEweekdays_apply.fit_transform(prev.WEEKDAYS_APPLY)
prev.CONTRACT_STATUS = LEcontract_status.fit_transform(prev.CONTRACT_STATUS)
prev.YIELD_GROUP = LEyield_group.fit_transform(prev.YIELD_GROUP)
prev.describe()

Уменьшение количества используемых переменных

В предыдущем наборе данных о кредите есть 20 переменных или столбцов, включая столбцы поведения. Можно добавить все 20 переменных в набор данных поезда, но это будет пустой тратой ресурсов. Почему? Потому что могут быть переменные, которые сильно коррелируют друг с другом, поэтому могут быть представлены другими переменными. Самый простой способ сократить данные — посмотреть на корреляционную матрицу всех переменных, описать коррелированные переменные и использовать одну из двух коррелированных переменных. Этого метода обычно избегают, потому что он требует глубокого понимания интерпретируемых данных, но я буду использовать его из соображений длины подготовки данных. Мы будем рассматривать переменные с корреляцией выше 0,3 или ниже -0,3.

prev.drop(['SK_ID_PREV'],axis = 1, inplace = True)
corrprevbhv = prev.corr()
corr_triuprevbhv = corrprevbhv.where(~np.tril(np.ones(corrprevbhv.shape)).astype(np.bool))
corr_triuprevbhv = corr_triuprevbhv.stack()
corr_triuprevbhv.name = 'Pearson Correlation Coefficient'
corr_triuprevbhv.index.names = ['First Var', 'Second Var']
corr_triuprevbhv[(corr_triuprevbhv > 0.3)|(corr_triuprevbhv < -0.3)].to_frame()

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

prevbhvfinal = prev[['LN_ID','CONTRACT_TYPE','CONTRACT_STATUS','AMT_DOWN_PAYMENT','PRICE','WEEKDAYS_APPLY','HOUR_APPLY','DAYS_DECISION','PREV_PAY_DEFICIT','PREV_LATENESS','TERMINATION']]

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

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

ТРЕТИЙ ЭТАП (группировка данных о предыдущем кредите и добавление их к данным трейн-теста)

contract_type = prevbhvfinal.groupby(['LN_ID'])['CONTRACT_TYPE'].agg(lambda x: mode(x)[0][0] if x.notnull().any() else np.nan )
contract_status = prevbhvfinal.groupby(['LN_ID'])['CONTRACT_STATUS'].agg(lambda x: mode(x)[0][0] if x.notnull().any() else np.nan)
amt_down_payment = prevbhvfinal.groupby(['LN_ID'])['AMT_DOWN_PAYMENT'].agg(lambda x: x.median() if x.notnull().any() else np.nan )
price = prevbhvfinal.groupby(['LN_ID'])['PRICE'].agg(lambda x: x.median() if x.notnull().any() else np.nan )
weekdays_apply = prevbhvfinal.groupby(['LN_ID'])['WEEKDAYS_APPLY'].agg(lambda x: mode(x)[0][0] if x.notnull().any() else np.nan )
hour_apply = prevbhvfinal.groupby(['LN_ID'])['HOUR_APPLY'].agg(lambda x: x.median() if x.notnull().any() else np.nan )
days_decision = prevbhvfinal.groupby(['LN_ID'])['DAYS_DECISION'].agg(lambda x: x.median() if x.notnull().any() else np.nan )
prev_pay_deficit = prevbhvfinal.groupby(['LN_ID'])['PREV_PAY_DEFICIT'].agg(lambda x: x.median() if x.notnull().any() else np.nan )
prev_lateness = prevbhvfinal.groupby(['LN_ID'])['PREV_LATENESS'].agg(lambda x: x.median() if x.notnull().any() else np.nan )
termination = prevbhvfinal.groupby(['LN_ID'])['TERMINATION'].agg(lambda x: x.median() if x.notnull().any() else np.nan )

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

LEincometype = LabelEncoder()
LEeducation = OrdinalEncoder(categories = [['Academic degree','Lower secondary','Secondary / secondary special','Incomplete higher','Higher education']])
LEfamilystatus = LabelEncoder()
LEhousingtypes = LabelEncoder()
LEorganizationtype = LabelEncoder()
train['PREV_CONTRACT_TYPE'] = train['LN_ID'].apply(lambda x: contract_type[x] if x in contract_type.index else np.nan)
train['PREV_AMT_DOWN_PAYMENT'] = train['LN_ID'].apply(lambda x: amt_down_payment[x] if x in amt_down_payment.index else np.nan)
train['PREV_PRICE'] = train['LN_ID'].apply(lambda x: price[x] if x in price.index else np.nan)
train['PREV_WEEKDAYS_APPLY'] = train['LN_ID'].apply(lambda x: weekdays_apply[x] if x in weekdays_apply.index else np.nan)
train['PREV_HOUR_APPLY'] = train['LN_ID'].apply(lambda x: hour_apply[x] if x in hour_apply.index else np.nan)
train['PREV_DAYS_DECISION'] = train['LN_ID'].apply(lambda x: days_decision[x] if x in days_decision.index else np.nan)
train['PREV_PAY_DEFICIT'] = train['LN_ID'].apply(lambda x: prev_pay_deficit[x] if x in prev_pay_deficit.index else np.nan)
train['PREV_LATENESS'] = train['LN_ID'].apply(lambda x: prev_lateness[x] if x in prev_lateness.index else np.nan)
train['PREV_TERMINATION'] = train['LN_ID'].apply(lambda x: termination[x] if x in termination.index else np.nan)
train['PREV_CONTRACT_STATUS'] = train['LN_ID'].apply(lambda x:contract_status[x] if x in contract_status.index else np.nan)
fortraingenddummy = pd.get_dummies(train.GENDER)
train['GENDER_F'], train['GENDER_M'] = fortraingenddummy['F'],fortraingenddummy['M']
train.drop('GENDER',axis = 1, inplace= True)
train['INCOME_TYPE'] = LEincometype.fit_transform(train['INCOME_TYPE'])
train['EDUCATION'] = LEeducation.fit_transform(train.loc[:,['EDUCATION']])
train['FAMILY_STATUS'] = LEfamilystatus.fit_transform(train['FAMILY_STATUS'])
train['HOUSING_TYPE'] = LEhousingtypes.fit_transform(train['HOUSING_TYPE'])
train['ORGANIZATION_TYPE'] = LEorganizationtype.fit_transform(train['ORGANIZATION_TYPE'])
train['CONTRACT_TYPE'] = LEcontract_type.transform(train['CONTRACT_TYPE'])
train['WEEKDAYS_APPLY'] = LEweekdays_apply.transform(train['WEEKDAYS_APPLY'])

То же самое делается для тестового набора данных.

ЧЕТВЕРТЫЙ ШАГ (Очистка данных поезда, моделей обучения и оценки)

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

from sklearn.impute import KNNImputer
from sklearn.preprocessing import StandardScaler
train.isnull().sum()/len(train)
test.isnull().sum()/len(test)

Процент отсутствующих значений не так велик, как процент отсутствующих значений в предыдущем наборе данных по кредитам. Это указывает на то, что большая часть SK_ID_PREV с отсутствующими значениями не является SK_ID_PREV для LN_ID, используемого в данных поезда. EXT_SCORE_1 содержит 50 % отсутствующих значений, что действительно важно. Мы удаляем этот столбец из-за того, что в нем содержится большое количество отсутствующих значений, что затрудняет его включение в модель.

train.drop('EXT_SCORE_1',axis = 1, inplace = True)
test.drop('EXT_SCORE_1',axis = 1, inplace=True)

Нам также нужно будет проверить дисбаланс классов, прежде чем передавать данные поезда в модель. Мы можем проверить это, просто используя value_counts() для переменной TARGET.

train.TARGET.value_counts()

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

scale = StandardScaler()
impute=KNNImputer()
xtrain = train.drop('TARGET',axis = 1, inplace = False)
ytrain = train['TARGET']
xtest = test.drop('TARGET',axis = 1, inplace = False)
ytest = test['TARGET']
xtrainscaled = pd.DataFrame(scale.fit_transform(xtrain),columns = xtrain.columns)
xtrainscaledimpute = pd.DataFrame(impute.fit_transform(xtrainscaled),columns = xtrainscaled.columns)
xtestscaled = pd.DataFrame(scale.transform(xtest),columns=xtest.columns)
xtestscaledimpute = pd.DataFrame(impute.fit_transform(xtestscaled),columns = xtestscaled.columns)

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

corr = train.corr()
corr_triu = corr.where(~np.tril(np.ones(corr.shape)).astype(np.bool))
corr_triu = corr_triu.stack()
corr_triu.name = 'Pearson Correlation Coefficient'
corr_triu.index.names = ['First Var', 'Second Var']
corr_triu[(corr_triu > 0.3) | (corr_triu < -0.3)].to_frame()

мы можем отказаться от PRICE и DAYS_WORK, а затем продолжить обработку дисбаланса классов. Мы будем использовать SMOTE для обработки дисбаланса классов.

xtrainscaledimpute.drop(['PRICE','DAYS_WORK'],axis = 1 , inplace = True)
xtestscaledimpute.drop(['PRICE','DAYS_WORK'], axis = 1, inplace = True)
from imblearn.over_sampling import SMOTE
sm = SMOTE(random_state = 42)
xtrainfinal, ytrainfinal = sm.fit_resample(xtrainscaledimpute, ytrain)

Моделирование

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

from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import Perceptron, LogisticRegression
from sklearn.metrics import classification_report,accuracy_score,roc_auc_score,f1_score
from sklearn.model_selection import GridSearchCV,RandomizedSearchCV
###logreg
logreg = GridSearchCV(LogisticRegression(max_iter=300),dict(solver = ['newton-cg', 'lbfgs', 'sag', 'saga']),
                      scoring='roc_auc')
logreg.fit(xtrainfinal,ytrainfinal)
logreg.best_estimator_
logregmodel = LogisticRegression(max_iter=300, solver='sag')
logregmodel.fit(xtrainfinal,ytrainfinal)
ypredlogreg = (logregmodel.predict_proba(xtestscaledimpute)[:,1]>=0.5).astype(int)
rocauclogreg = round(roc_auc_score(ytest,ypredlogreg),3)
classreportlogreg = classification_report(ytest,ypredlogreg)
print('Logistic Regression Classification Report\n'+classreportlogreg+"\nROC AUC Score: "+str(rocauclogreg)+"\nF1-Score: "+str(f1_score(ytest,ypredlogreg)))
acclogreg = round(accuracy_score(ytest,ypredlogreg),3)
###Perceptron
Perceptron = GridSearchCV(Perceptron(random_state = 42),dict(penalty=['l2','l1','elasticnet','None'],
                                                             class_weight = ['balanced','None']))
Perceptron.fit(xtrainfinal,ytrainfinal)
ypredPercept = Perceptron.predict(xtestscaledimpute)
rocaucPercept = round(roc_auc_score(ytest,ypredPercept),3)
classreportPercept = classification_report(ytest,ypredPercept)
print('Perceptron Classification Report\n'+classreportPercept+"\nROC AUC Score: "+str(rocaucPercept)+"\nF1-Score: "+str(f1_score(ytest,ypredPercept)))
accPercept= round(accuracy_score(ytest,ypredPercept),3)
###RandomForestClassifier
RF = RandomizedSearchCV(RandomForestClassifier(random_state=42),dict(n_estimators=[100,150,200],
                                                criterion = ['gini','entropy'],
                                                max_features = ['sqrt','log2']),
                        random_state=42,scoring='roc_auc')
RF.fit(xtrainfinal,ytrainfinal)
RF.best_params_
RFmodel = RandomForestClassifier(n_estimators= 200, max_features= 'log2', criterion= 'entropy',random_state=42)
RFmodel.fit(xtrainfinal,ytrainfinal)
ypredrf = (RFmodel.predict_proba(xtestscaledimpute)[:,1]>=0.5).astype(int)
rocaucrf = round(roc_auc_score(ytest,ypredrf),3)
classreportrf = classification_report(ytest,ypredrf)
print('Random Forest Classification Report \n'+classreportrf+"\nROC AUC Score: "+str(rocaucrf)+"\nF1-Score: "+str(f1_score(ytest,ypredrf)))
accrf = round(accuracy_score(ytest,ypredrf),3)

Мы видим, что среди всех моделей Perceptron и Logistic Regression кажутся лучшими альтернативами. Мы измеряем производительность модели, обращая внимание на показатель F1, показатель ROC AUC и показатель точности.

Примечание. В этом проекте я решил измерять производительность модели с помощью F1-Score, ROC AUC и Accuracy, поскольку цель состоит в том, чтобы максимизировать прогноз, сделанный для обоих классов (клиенты, которые склонны опаздывать с платежами, и клиенты, которые склонны платить вовремя). Если цель состоит в том, чтобы уменьшить количество ложноотрицательных результатов (клиент опаздывает, но мы этого не обнаружили), мы должны использовать Вспомните, чтобы измерить производительность модели.

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

###logreg
logreg = GridSearchCV(LogisticRegression(max_iter=300),dict(solver = ['newton-cg', 'lbfgs', 'sag', 'saga']), scoring='roc_auc')
logreg.fit(xtrainfinal,ytrainfinal)
logreg.best_estimator_
logregmodel = LogisticRegression(max_iter=300, solver='sag')
logregmodel.fit(xtrainfinal,ytrainfinal)
ypredlogreg = (logregmodel.predict_proba(xtestscaledimpute)[:,1]>=0.62).astype(int)
rocauclogreg = round(roc_auc_score(ytest,ypredlogreg),3)
classreportlogreg = classification_report(ytest,ypredlogreg)
print('Logistic Regression Classification Report\n'+classreportlogreg+"\nROC AUC Score: "+str(rocauclogreg)+"\nF1-Score: "+str(f1_score(ytest,ypredlogreg)))
acclogreg = round(accuracy_score(ytest,ypredlogreg),3)
###RandomForestClassifier
RF = RandomizedSearchCV(RandomForestClassifier(random_state=42),dict(n_estimators=[100,150,200], criterion = ['gini','entropy'], max_features = ['sqrt','log2']),random_state=42,scoring='roc_auc')
RF.fit(xtrainfinal,ytrainfinal)
RF.best_params_
RFmodel = RandomForestClassifier(n_estimators= 200, max_features= 'log2', criterion= 'entropy',random_state=42)
RFmodel.fit(xtrainfinal,ytrainfinal)
ypredrf = (RFmodel.predict_proba(xtestscaledimpute)[:,1]>=0.318).astype(int)
rocaucrf = round(roc_auc_score(ytest,ypredrf),3)
classreportrf = classification_report(ytest,ypredrf)
print('Random Forest Classification Report \n'+classreportrf+"\nROC AUC Score: "+str(rocaucrf)+"\nF1-Score: "+str(f1_score(ytest,ypredrf)))
accrf = round(accuracy_score(ytest,ypredrf),3)

Логистическая регрессия и случайный лес работают намного лучше, чем логистическая регрессия и случайный лес, без изменения предела предсказанных вероятностей. Точность Random Forest снизилась с 90% до 80% из-за изменения отсечки, но его показатель ROC AUC и F1-Score значительно увеличились. Мы уделяем больше внимания оценке ROC AUC и F1-Score, поскольку обе метрики описывают, насколько хорошо модель может предсказать каждый класс (в данном случае кредит по умолчанию или обычный кредит).

Вывод

Сама модель не идеальна. Показатель ROC AUC ‹0,7 по-прежнему считается плохим. Но цель этого письма состоит в том, чтобы описать один из подходов, которые мы можем использовать для построения модели, определяющей просроченные платежи по кредитным заявкам.

Вещи, которые я мог бы попытаться сделать, но не сделал

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

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


Спасибо за чтение!