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

Эта статья состоит из 4 частей:

  • Часть 1: Общая картина. Это относительно небольшой обзор проекта. Я сосредотачиваюсь на бизнес-проблеме и влиянии модели.
  • Часть 2: Предварительная обработка данных и исследовательский анализ данных. Это подробное объяснение этапа подготовки данных.
  • Часть 3: Моделирование и интерпретация. Это подробное объяснение этапов моделирования и оценки модели.
  • Часть 4. Развертывание модели в Google Cloud. Я описываю, как запустить модель в производство.

Введение

Lending Club был платформой для однорангового кредитования. За 2007–2020 годы выдано не менее 2,9 млн кредитов. Размер ссуд варьировался от 1000 до 40 000 долларов и использовался для рефинансирования потребительских кредитов или покрытия расходов среднего размера.

После принятия заявки на получение кредита Lending Club использовала внутреннюю модель риска для присвоения кредитного рейтинга и процентной ставки по кредиту. Затем он разместил кредит на онлайн-платформе, где инвесторы могли инвестировать в него. Инвесторы полностью несли кредитный риск в обмен на получение процентных платежей.

Таким образом, ключевой проблемой инвесторов Lending Club было определение рискованности кредита. Цель инвесторов — избегать рискованных кредитов, процентная ставка по которым недостаточна для покрытия ожидаемых кредитных убытков. Хотя Lending Club больше не занимается одноранговым кредитованием, в настоящее время существует ряд кредитных платформ P2P. Цель этого проекта — решить проблему инвесторов путем построения модели машинного обучения.

Ниже я описываю, как я построил модель XGBoost для прогнозирования дефолтов по кредитам. Я включаю только самые важные части кода. Полную версию см. в базовом блокноте Kaggle. Модель имеет точность 60,3% при полноте 10%. Это позволяет инвесторам Lending Club сэкономить 59,7 млн ​​долларов, избегая самых рискованных кредитов. Экономия достигается за счет размещения инвестиционных средств в безрисковые облигации, а не в самые рискованные кредиты.

Предварительная обработка данных

Во-первых, давайте загрузим набор данных:

with open('../input/lc-800k-sample/LCLoans_141_800k.pkl', 'rb') as pickled_one:
    df = pickle.load(pickled_one)
display(df.shape, df.head())

Набор данных содержит почти 900 тыс. экземпляров и 68 переменных. Давайте создадим целевую переменную. Он равен 1 по умолчанию и 0 в противном случае. Уровень по умолчанию в моем образце составляет 19,6%.

# create a target variable

display(df.loan_status.value_counts())
df.target = np.nan
df.loc[df.loan_status.isin(['Fully Paid']), 'target'] = 0
df.loc[df.loan_status.isin(['Charged Off', 'Default']), 'target'] = 1
df = df[df['target'].isin([0,1])]
df.drop(columns='loan_status',inplace=True)

Разработка функций

Есть много особенностей, связанных с размером кредита, который измеряется в долларах. Чтобы сделать эти характеристики сопоставимыми по кредитам, мы должны масштабировать их по доходам заемщиков. Затем я выполняю сплит «поезд-тест».

# add key loan features, scaled by borrower's income:

df.loc[df.annual_inc<1,'annual_inc'] = 1
df['lti'] = df.loan_amnt/df.annual_inc
df['iti'] = (df.installment*12)/df.annual_inc
df.loc[df.lti==np.inf, 'lti'] = np.nan
df.loc[df.lti>1.5, 'lti'] = 1.5
df.loc[df.iti==np.inf, 'iti'] = np.nan
df.loc[df.iti>1, 'iti'] = 1
df.loc[df.revol_util>100,'revol_util'] = 100
df.loc[df.dti>100, 'dti'] = 100
df.loc[df.dti<0, 'dti'] = 0

df['revol_balance_income'] = df.revol_bal/df.annual_inc
df['avg_cur_bal_inc'] = df.avg_cur_bal/df.annual_inc
df['tot_cur_bal_inc'] = df.tot_cur_bal/df.annual_inc
df['total_bal_ex_mort_inc'] = df.total_bal_ex_mort/df.annual_inc
df['total_rev_inc'] = df.total_rev_hi_lim/df.annual_inc
df['open_cl_ratio'] = df.open_acc/df.total_acc

# add more features

df['zip_code'] = df.zip_code.str.rstrip('xx').astype(int)
df['joint'] = df.dti_joint.notnull().astype(int)
df['emp_length'] = df.emp_length.str.rstrip(' years')
df.loc[df.emp_length=='< 1','emp_length'] = 0
df.loc[df.emp_length=='10+','emp_length'] = 10
df['emp_length'] = df.emp_length.astype(np.float32)
display(df.emp_length.value_counts())
df.amnt_same = (df.loan_amnt == df.funded_amnt_inv).astype(int)
df['low_fico'] = (df.fico_range_high<=659).astype(int)
df.loc[df.home_ownership.isin(['ANY','NONE','OTHER']), 'home_ownership'] = 'OTHER'
df['was_bankrupt'] = (df.pub_rec_bankruptcies>0).astype(int)

df.drop(columns = ['annual_inc_joint', 
                   'dti_joint', 
                   'verification_status_joint', 
                   'earliest_cr_line', 
                   'issue_d'], 
        inplace=True)

df.head()
test_size = 0.25
df.reset_index(inplace=True, drop=True)
random.seed(2)
test_index = random.sample(list(df.index), int(test_size*df.shape[0]))
train = df.iloc[list(set(df.index)-set(test_index))]
test = df.iloc[test_index]
train.reset_index(drop=True, inplace=True)
test.reset_index(drop=True, inplace=True)
train.drop(columns=['id'],inplace=True)
test.drop(columns=['id'],inplace=True)

Есть 11 категориальных признаков. Чтобы использовать их для прогнозного моделирования, нам нужно преобразовать их значения в числовые значения. Для этого я использую целевое кодирование и однократное кодирование. Горячее кодирование больше подходит для функций с очень небольшим количеством уникальных значений (т. е. с низкой кардинальностью). Для объектов с более чем 3 уникальными значениями горячее кодирование приведет к значительному увеличению размерности пространства признаков. Поэтому я использую целевую кодировку для таких функций. Грубо говоря, целевое кодирование заменяет категориальный признак средним значением целевого значения по категориям.

# I use target encoding (te) and one-hot encoding (ohe) 
# for these two sets of categorical features
cat_features_te = ['sub_grade', 
                   'emp_title', 
                   'purpose', 
                   'title', 
                   'zip_code', 
                   'addr_state', 
                   'grade', 
                   'home_ownership']
cat_features_ohe = ['verification_status', 
                    'initial_list_status', 
                    'application_type']
# TE for categorical features

encoder = CrossFoldEncoder(MEstimateEncoder, m=10)
train_encoded = encoder.fit_transform(train, train.target, cols=cat_features_te)
test_encoded = encoder.transform(test)

train.drop(columns=cat_features_te, inplace=True)
test.drop(columns=cat_features_te,  inplace=True)
train = pd.concat([train, train_encoded], axis = 1)
test = pd.concat([test, test_encoded], axis = 1)
X_train = train.copy()
y_train = X_train.pop('target')
X_test = test.copy()
y_test = X_test.pop('target')
display(X_test.head())

### Do OHE for some features ###

feature_transformer = ColumnTransformer([
    ("cat", OneHotEncoder(sparse = False, handle_unknown="ignore", drop='if_binary'), 
     cat_features_ohe)], remainder="passthrough")

print('Number of features before transaformation: ', X_train.shape)
X_train = pd.DataFrame(feature_transformer.fit_transform(X_train), 
                       columns=feature_transformer.get_feature_names_out())
X_test = pd.DataFrame(feature_transformer.transform(X_test), 
                      columns=feature_transformer.get_feature_names_out())
X_train.columns = X_train.columns.str.replace(r'^cat__', '').str.replace(r'^remainder__', '')
X_test.columns = X_test.columns.str.replace(r'^cat__', '').str.replace(r'^remainder__', '')

Теперь мы закончили с подготовкой данных и готовы построить модель. Всего 79 функций.

Производительность модели и ее интерпретация

Ниже я создаю модель XGBoost для прогнозирования дефолтов по кредитам. Я использую гиперпараметры, подобранные с помощью Optuna. Они хранятся в словаре «starting_hyperparameters».

optuna_hyperpars = starting_hyperparameters
optuna_xgb = XGBClassifier(**optuna_hyperpars, seed=8)
optuna_xgb.fit(X_train, y_train)

precision_t, recall_t, threshold = \
precision_recall_curve(y_train, optuna_xgb.predict_proba(X_train)[:, 1])
auc_precision_recall_train = auc(recall_t, precision_t)
temp = recall_t[(recall_t>0.095)&(recall_t<0.105)]
temp = temp[int(len(temp)/2)]
indexx = ((np.where(recall_t==temp)))[0][0]
r10prec_train = precision_t[indexx]

fig, ax = plt.subplots()
ax.plot(recall_t, precision_t, color='purple')
ax.set_title('Precision-Recall Curve, train')
ax.set_ylabel('Precision')
ax.set_xlabel('Recall')
ax.set_ylim(bottom=0, top=1.02)
plt.show()

precision_t, recall_t, threshold = \
precision_recall_curve(y_test, optuna_xgb.predict_proba(X_test)[:, 1])
auc_precision_recall_test = auc(recall_t, precision_t)
temp = recall_t[(recall_t>0.095)&(recall_t<0.105)]
temp = temp[int(len(temp)/2)]
indexx = ((np.where(recall_t==temp)))[0][0]
r10prec_test = precision_t[indexx]

fig, ax = plt.subplots()
ax.plot(recall_t, precision_t, color='purple')
ax.set_title('Precision-Recall Curve, test')
ax.set_ylabel('Precision')
ax.set_xlabel('Recall')
ax.set_ylim(bottom=0, top=1.02)
plt.show()

display('Train Accuracy: ', accuracy_score(y_train,optuna_xgb.predict(X_train)))
display('F1 score: ', f1_score(y_train,optuna_xgb.predict(X_train)))
display('ROCAUC: ', roc_auc_score(y_train,optuna_xgb.predict(X_train)))
display('PRAUC: ', auc_precision_recall_train)
display('R10P: ', r10prec_train)
# Performance evaluation:
display('Test Accuracy: ', accuracy_score(y_test,optuna_xgb.predict(X_test)))
display('F1 score: ', f1_score(y_test,optuna_xgb.predict(X_test)))
display('ROCAUC: ', roc_auc_score(y_test,optuna_xgb.predict(X_test)))
display('PRAUC: ', auc_precision_recall_test)
display('R10P: ', r10prec_test)
display('Time to do hyperparameter optimization: ', time.time()-time1)

На первый взгляд, производительность модели довольно плохая: оценка F1 составляет всего 19%, а ROCAUC — всего 55%. Это означает, что модель работает не намного лучше, чем случайное предположение (ROCAUC 50%). Однако кривая точности-отзыва показывает, что модель хорошо работает для небольшого числа рискованных кредитов. При отзыве 3% точность превышает 75%. Это означает, что для 3% самых рискованных кредитов модель правильно предсказывает дефолт более чем в 75% случаев. Это намного лучше, чем 20% от случайного предположения.

Приведенный ниже анализ показывает важность функции.

explainerxgbc = shap.TreeExplainer(optuna_xgb)
shap_values_XGBoost_test = explainerxgbc.shap_values(X_test)

vals = np.abs(shap_values_XGBoost_test).mean(0)
feature_names = X_test.columns
feature_importance = pd.DataFrame(list(zip(feature_names, vals)),
                                 columns=['col_name','feature_importance_vals'])
feature_importance.sort_values(by=['feature_importance_vals'],
                              ascending=False, inplace=True)
shap.summary_plot(shap_values_XGBoost_test, 
                  X_test, plot_type="bar", plot_size=(8,8), max_display=15)

Наиболее важной особенностью является кредит subgrade. Это неудивительно. Более высокий рейтинг кредита, естественно, предсказывает более низкую вероятность дефолта. Другими важными переменными являются отношения долга к доходу (lti и dti), занятость и домовладение заемщика. Удивительно, но процентная ставка является лишь 10-й по важности характеристикой. Мы могли бы ожидать, что процентная ставка вместе с рейтингом кредита будут наиболее важными характеристиками. Тот факт, что более высокая процентная ставка не является надежным предиктором более высокого риска дефолта, намекает на то, что существует большое пространство для улучшения внутренней модели риска Lending Club, которая определяла процентные ставки.

Влияние на бизнес

Если вы рассматриваете этот проект исключительно как упражнение по прогнозированию, то, возможно, это провал. Точность модели составляет 81%, что едва превышает 80% по сравнению с тривиальной моделью, всегда предсказывающей отсутствие дефолта. Оценка F1 составляет всего 18%, что не впечатляет. ROCAUC составляет 55%, что чуть лучше, чем случайное предположение.

Тем не менее, эта модель может создавать ценность при правильном использовании. Позвольте мне показать вам, как это сделать.

Приведенная выше кривая точности-отзыва показывает, что модель на самом деле очень хорошо предсказывает дефолты примерно для 10% просроченных кредитов. Например, его точность при 10% отзыве составляет 60%. Это означает, что если мы используем модель только для прогнозирования дефолта по тем кредитам, в которых она наиболее уверена, мы можем избежать по крайней мере 10% кредитных убытков, отказываясь при этом от относительно небольшого количества выгодных возможностей кредитования.

Код ниже показывает, что эта модель экономит деньги инвесторов. Вместо того, чтобы инвестировать во все кредиты, мы можем инвестировать в 90% кредитов, исключая 10% самых рискованных кредитов, как определено моделью. Вложив оставшиеся 10 % средств в безрисковый актив, мы можем сэкономить 60 млн долларов США.

recoveries['total_recovery'] = \
recoveries.total_rec_prncp + recoveries.total_rec_int + recoveries.recoveries
recoveries['tot_recov_rp'] = recoveries.total_recovery/recoveries.loan_amnt
recoveries['tot_recov_rt'] = \
recoveries.total_recovery/(recoveries.loan_amnt*((recoveries.int_rate/100+1)**3))

X_test = X_test_0.copy()
test = X_test[['loan_amnt', 'int_rate']]
test.int_rate = test.int_rate/100+1
test['y_pred'] = optuna_xgb.predict_proba(X_test)[:,1]
test['id'] = test00.id
test['y'] = y_test
test = pd.merge(test, recoveries[['id', 'loan_amnt', 'int_rate', 'total_recovery']], on='id', how = 'left')
display(recoveries.head(), test.head())
display(test.loc[test.y==0].count(), test.loc[test.y==1].count())

# select riskiest loans by calculating decision threshold, giving 10% recall:

desired_recall = 0.1

temp = recall_t[(recall_t>(desired_recall-0.001))&(recall_t<(desired_recall+0.001))]
temp = temp[int(len(temp)/2)]
indexx = ((np.where(recall_t==temp)))[0][0]
r10threshold = threshold[indexx]
p90risk = r10threshold
test['p90risk'] = (test.y_pred>=p90risk).astype(int)

risky_loans = test[test.p90risk==1]
risky_loans.loc[risky_loans.total_recovery.isnull(),'total_recovery']=\
risky_loans.loan_amnt_x*(risky_loans.int_rate_x**3)
# when the loan is repaid, I calculate total return 
# and save it into total_recovery column.

proceeds = risky_loans.total_recovery.sum()
proceeds_tnotes = (risky_loans.loan_amnt_x.sum())*(1.02**3)
print("Investors' total returns from investing in risky loans", int(proceeds))
print("Investors' total returns from investing in T-notes", int(proceeds_tnotes))
print("Investors' savings: $", int(proceeds_tnotes-proceeds))
estimated_savings = (proceeds_tnotes-proceeds)*3.3*4
print("Estimated investors' savings from all LendingClub loans: $", estimated_savings)

# In this particular sample, 
# the savings are maximized at 8.5% recall threshold.
# Then the saving for investors will be $4.9M in test set 
# or 64.6M for all loans.

Эта статья является первой в последовательности из четырех сообщений, описывающих модель ML в случае дефолта по кредиту. Следующие две части будут более техническими. Я рассмотрю весь код и подробно объясню, как построить эту модель машинного обучения.