Пример решения задачи кредитного скоринга c помощью связки python+pandas+scikit-learn

в 11:07, , рубрики: Без рубрики

Введение

Добрый день, уважаемые читатели.
Недавно, бродя по просторам глобальной паутины, я наткнулся на турнир, который проводился банком ТКС в начале этого года. Ознакомившись с заданиями, я решил проверить свои навыки в анализе данных на них.
Начать проверку я решил с задачи о скоринге (Задание №3). Для ее решения я, как всегда, использовал Python с аналитическими модулями pandas и scikit-learn.

Описание данных и постановка задачи

Банк запрашивает кредитную историю заявителя в трех крупнейших российских кредитных бюро. Предоставляется выборка клиентов Банка в файле SAMPLE_CUSTOMERS.CSV. Выборка разделена на части «train» и «test». По выборке «train» известно значение целевой переменной bad — наличие "дефолта" (допущение клиентом просрочки 90 и более дней в течение первого года пользования кредитом). В файле SAMPLE_ACCOUNTS.CSV предоставлены данные из ответов кредитных бюро на все запросы по соответствующим клиентам.
Формат данных SAMPLE_CUSTOMERS – информация о возможности дефолта определенного человека.
Описание формата набора данных SAMPLE_ACCOUNTS:

Описание набора
Name Description
TCS_CUSTOMER_ID Идентификатор клиента
BUREAU_CD Код бюро, из которого получен счет
BKI_REQUEST_DATE Дата, в которую был сделан запрос в бюро
CURRENCY Валюта договора (ISO буквенный код валюты)
RELATIONSHIP Тип отношения к договору
1 — Физическое лицо
2 — Дополнительная карта/Авторизованный пользователь
4 — Совместный
5 — Поручитель
9 — Юридическое лицо
OPEN_DATE Дата открытия договора
FINAL_PMT_DATE Дата финального платежа (плановая)
TYPE Код типа договора
1 – Кредит на автомобиль
4 – Лизинг
6 – Ипотека
7 – Кредитная карта
9 – Потребительский кредит
10 – Кредит на развитие бизнеса
11 – Кредит на пополнение оборотных средств
12 – Кредит на покупку оборудования
13 – Кредит на строительство недвижимости
14 – Кредит на покупку акций (например, маржинальное кредитование)
99 – Другой
PMT_STRING_84M Дисциплина (своевременность) платежей. Строка составляется из кодов состояний счета на моменты передачи банком данных по счету в бюро, первый символ — состояние на дату PMT_STRING_START, далее последовательно в порядке убывания дат.
0 – Новый, оценка невозможна
X – Нет информации
1 – Оплата без просрочек
A – Просрочка от 1 до 29 дней
2 – Просрочка от 30 до 59 дней
3 – Просрочка от 60 до 89 дней
4 – Просрочка от 90 до 119 дней
5 – Просрочка более 120 дней
7 – Регулярные консолидированные платежи
8 – Погашение по кредиту с использованием залога
9 – Безнадёжный долг/ передано на взыскание/ пропущенный платеж
STATUS Статус договора
00 – Активный
12 – Оплачен за счет обеспечения
13 – Счет закрыт
14 – Передан на обслуживание в другой банк
21 – Спор
52 – Просрочен
61 – Проблемы с возвратом
OUTSTANDING Оставшаяся непогашенная задолженность. Сумма в рублях по курсу ЦБ РФ
NEXT_PMT Размер следующего платежа. Сумма в рублях по курсу ЦБ РФ
INF_CONFIRM_DATE Дата подтверждения информации по счету
FACT_CLOSE_DATE Дата закрытия счета (фактическая)
TTL_DELQ_5 Количество просрочек до 5 дней
TTL_DELQ_5_29 Количество просрочек от 5 до 29 дней
TTL_DELQ_30_59 Количество просрочек от 30 до 59 дней
TTL_DELQ_60_89 Количество просрочек от 60 до 89 дней
TTL_DELQ_30 Количество просрочек до 30 дней
TTL_DELQ_90_PLUS Количество просрочек 90+ дней
PMT_FREQ Код частоты платежей
  1 – Еженедельно
2 – Раз в две недели
3 – Ежемесячно
A — Раз в 2 месяца
4 – Поквартально
B — Раз в 4 месяца
5 – Раз в полгода
6 — Ежегодно
7 – Другое
CREDIT_LIMIT Кредитный лимит. Сумма в рублях по курсу ЦБ РФ
DELQ_BALANCE Текущая просроченная задолженность. Сумма в рублях по курсу ЦБ РФ
MAX_DELQ_BALANCE Максимальный объем просроченной задолженности. Сумма в рублях по курсу ЦБ РФ
CURRENT_DELQ Текущее количество дней просрочки
PMT_STRING_START Дата начала строки PMT_STRING_84M
INTEREST_RATE Процентная ставка по кредиту
CURR_BALANCE_AMT Общая выплаченная сумма, включая сумму основного долга, проценты, пени и штрафы. Сумма в рублях по курсу ЦБ РФ

Задача состоит в том, чтобы на выборке «train» необходимо построить модель, определяющую вероятность «дефолта», и проставить вероятности ее по клиентам из выборки «test». Для оценки модели будет использоваться характеристика Area Under ROC Curve (также указано в условиях задачи).

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

Для начала загрузим исходные файлы и посмотрим на них:

from pandas import read_csv, DataFrame
from sklearn.metrics import roc_curve
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.cross_validation import train_test_split
from sklearn.naive_bayes import GaussianNB
from sklearn.neighbors import KNeighborsClassifier
from sklearn.decomposition import PCA
import ml_metrics, string, re, pylab as pl

SampleCustomers = read_csv("https://static.tcsbank.ru/documents/olymp/SAMPLE_CUSTOMERS.csv", ';')
SampleAccounts = read_csv("https://static.tcsbank.ru/documents/olymp/SAMPLE_ACCOUNTS.csv",";",decimal =',')

print SampleAccounts

image

SampleCustomers.head()

tcs_customer_id bad sample_type
0 1 NaN test
1 2 0 train
2 3 1 train
3 4 0 train
4 5 0 train

Из условий задачи можно предположить, что набор SampleAccounts содержит несколько записей по одному заемщику давайте проверим это:

SampleAccounts.tcs_customer_id.drop_duplicates().count(), SampleAccounts.tcs_customer_id.count()

Наше предположение оказалось верным. Уникальных заемщиков 50000 из 280942 записей. Это связано с тем, что у одно заемщика быть несколько кредитов и по каждому из них в разных бюро моте быть разная информация. Следовательно, надо выполнить преобразования над SampleAccounts, чтобы одному заемщику соответствовала одна строка.
Теперь давайте получим список все уникальных кредитов по каждому заемщику:

SampleAccounts[['tcs_customer_id','open_date','final_pmt_date','credit_limit','currency']].drop_duplicates()

Следовательно, когда мы получили список кредитов, мы сможем вывести какую-либо общую информацию по каждому элементу списка. Т.е. можно было бы взять связку из перечисленных выше полей и сделать ее индексом, относительно которого мы бы производили дальнейшие манипуляции, но, к сожалению, тут нас подстерегает один неприятный момент. Он заключается в том, что поле 'final_pmt_date' в наборе данных имеет незаполненные значения. Давайте попробуем избавиться от них.
У нас в наборе есть поле фактическая дата закрытия кредита, следовательно, если она есть, а поле 'final_pmt_date' не заполнено, то можно в него записать данное значение. Для остальных же просто запишем 0.

SampleAccounts.final_pmt_date[SampleAccounts.final_pmt_date.isnull()] = SampleAccounts.fact_close_date[SampleAccounts.final_pmt_date.isnull()].astype(float)
SampleAccounts.final_pmt_date.fillna(0, inplace=True)

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

sumtbl = SampleAccounts.pivot_table(['inf_confirm_date'],  ['tcs_customer_id','open_date','final_pmt_date','credit_limit','currency'], aggfunc='max')
sumtbl.head(15)

inf_confirm_date
tcs_customer_id open_date final_pmt_date credit_limit currency
1 39261 39629 19421 RUB 39924
39505 39870 30000 RUB 39862
39644 40042 11858 RUB 40043
39876 41701 300000 RUB 40766
39942 40308 19691 RUB 40435
40421 42247 169000 RUB 40756
40428 51386 10000 RUB 40758
40676 41040 28967 RUB 40764
2 40472 40618 7551 RUB 40661
40652 40958 21186 RUB 40661
3 39647 40068 22694 RUB 40069
40604 0 20000 RUB 40624
4 38552 40378 75000 RUB 40479
39493 39797 5000 RUB 39823
39759 40123 6023 RUB 40125

Теперь добавим полученные нами даты к основному набору:

SampleAccounts = SampleAccounts.merge(sumtbl, 'left', 
                                     left_on=['tcs_customer_id','open_date','final_pmt_date','credit_limit','currency'], 
                                     right_index=True,
                                     suffixes=('', '_max'))

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

  • pmt_string_84m
  • pmt_freq
  • type
  • status
  • relationship
  • bureau_cd

Код для их преобразования приведен ниже:

# преобразуем pmt_string_84m
vals = list(xrange(10)) + ['A','X']
PMTstr = DataFrame([{'pmt_string_84m_%s' % (str(j)): str(i).count(str(j)) for j in vals} for i in SampleAccounts.pmt_string_84m])
SampleAccounts = SampleAccounts.join(PMTstr).drop(['pmt_string_84m'], axis=1)

# преобразуем pmt_freq
SampleAccounts.pmt_freq.fillna(7, inplace=True)
SampleAccounts.pmt_freq[SampleAccounts.pmt_freq == 0] = 7
vals = list(range(1,8)) + ['A','B']
PMTstr = DataFrame([{'pmt_freq_%s' % (str(j)): str(i).count(str(j)) for j in vals} for i in SampleAccounts.pmt_freq])
SampleAccounts = SampleAccounts.join(PMTstr).drop(['pmt_freq'], axis=1)

# преобразуем type
vals = [1,4,6,7,9,10,11,12,13,14,99]
PMTstr = DataFrame([{'type_%s' % (str(j)): str(i).count(str(j)) for j in vals} for i in SampleAccounts.type])
SampleAccounts = SampleAccounts.join(PMTstr).drop(['type'], axis=1)

# преобразуем status
vals = [0,12, 13, 14, 21, 52,61]
PMTstr = DataFrame([{'status_%s' % (str(j)): str(i).count(str(j)) for j in vals} for i in SampleAccounts.status])
SampleAccounts = SampleAccounts.join(PMTstr).drop(['status'], axis=1)

# преобразуем relationship
vals = [1,2,4,5,9]
PMTstr = DataFrame([{'relationship_%s' % (str(j)): str(i).count(str(j)) for j in vals} for i in SampleAccounts.relationship])
SampleAccounts = SampleAccounts.join(PMTstr).drop(['relationship'], axis=1)

# преобразуем bureau_cd
vals = [1,2,3]
PMTstr = DataFrame([{'bureau_cd_%s' % (str(j)): str(i).count(str(j)) for j in vals} for i in SampleAccounts.bureau_cd])
SampleAccounts = SampleAccounts.join(PMTstr).drop(['bureau_cd'], axis=1)

Следующим шагом, преобразуем поле 'fact_close_date', в котором содержится дата последнего фактического платежа, чтобы в нем содержалось только 2 значения:

  • 0 — не было последнего платежа
  • 1 — последний платеж был

Данную замену я сделал потому, что изначально поле было заполнено наполовину.

SampleAccounts.fact_close_date[SampleAccounts.fact_close_date.notnull()] = 1
SampleAccounts.fact_close_date.fillna(0, inplace=True)

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

PreFinalDS = SampleAccounts[SampleAccounts.inf_confirm_date == SampleAccounts.inf_confirm_date_max].drop_duplicates()

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

PreFinalDS = PreFinalDS.groupby(['tcs_customer_id','open_date','final_pmt_date','credit_limit','currency']).max().reset_index()

Наши данные почти готовы к началу анализа. Осталось выполнить еще несколько действий:

  1. Убрать не нужные столбцы
  2. Привести все кредитные лимиты в рубли
  3. Посчитать какое количество кредитов у каждого заемщика по информации от бюро

Начнем с очистки таблицы от ненужных столбцов:

PreFinalDS = PreFinalDS.drop(['bki_request_date',
                              'inf_confirm_date',
                              'pmt_string_start',
                              'interest_rate',
                              'open_date',
                              'final_pmt_date',
                              'inf_confirm_date_max'], axis=1)

Далее переведем все кредитные лимиты к рублям. Для простоты я взял курсы валют на текущий момент. Хотя правильнее наверное было бы брать курс на момент открытия счета. Еще один нюанс, в том, что для анализа нам надо убрать текстовое поле «сurrency», поэтому после перевода валют в рубли мы проведем с этим полем манипуляцию, которые мы провели с полями выше:

curs = DataFrame([33.13,44.99,36.49,1], index=['USD','EUR','GHF','RUB'], columns=['crs'])
PreFinalDS = PreFinalDS.merge(curs, 'left', left_on='currency', right_index=True)
PreFinalDS.credit_limit = PreFinalDS.credit_limit * PreFinalDS.crs

#выделяем значения в отдельные столбцы
vals = ['RUB','USD','EUR','CHF']
PMTstr = DataFrame([{'currency_%s' % (str(j)): str(i).count(str(j)) for j in vals} for i in PreFinalDS.currency])
PreFinalDS = PreFinalDS.join(PMTstr).drop(['currency','crs'], axis=1)

Итак перед заключительной группировкой добавим к нашему набору поле заполненное единицами. Т.е. когда мы выполним последнюю группировку, сумма по нему даст количество кредитов у заемщика:

PreFinalDS['count_credit'] = 1

Теперь, когда у нас в наборе данных все данные количественные, можно заполнить пробелы в данных 0 и выполнить заключительную группировку по клиенту:

PreFinalDS.fillna(0, inplace=True)
FinalDF = PreFinalDS.groupby('tcs_customer_id').sum()
FinalDF

Предварительный анализ

Ну что же первичная обработка данных завершена и можно приступить к их анализу. Для начала разделим наши данные на обучающую и тестовую выборки. В этом нам поможет столбец «sample_type» из SampleCustomers, по нему как раз сделано такое разделение.
Для того чтобы разбить наш обработанный DataFrame, достаточно объединить его с SampleCustomers поиграться фильтрами:

SampleCustomers.set_index('tcs_customer_id', inplace=True)
UnionDF = FinalDF.join(SampleCustomers)
trainDF = UnionDF[UnionDF.sample_type == 'train'].drop(['sample_type'], axis=1)
testDF = UnionDF[UnionDF.sample_type == 'test'].drop(['sample_type'], axis=1)

Далее давайте посмотрим, как признаки коррелирует между собой, для этого построим матрицу с коэффициентами корреляции признаков. С помощью pandas это можно сделать одной командой:

CorrKoef = trainDF.corr()

После действия выше CorrKoef будет содержать матрицу размеров 61x61.
Строками и столбцами ее будут соответствующие имена полей, а на их пересечении — значение коэффициента корреляции. Например:

fact_close_date
status_13 0.997362

Возможен случай, когда коэффициента корреляции нет. Это значит, что в данные поля скорее всего заполнены только одним одинаковым значением и их можно опустить при анализе. Проверим:

FieldDrop = [i for i in CorrKoef if CorrKoef[i].isnull().drop_duplicates().values[0]]

На выходе мы получили список полей которые можно удалить:

  • pmt_string_84m_6
  • pmt_string_84m_8
  • pmt_freq_5
  • pmt_freq_A
  • pmt_freq_B
  • status_12

Следующим шагом мы найдем поля которые коррелируют между собой (у которых коэффициент корреляции больше 90%), используя нашу матрицу:

CorField = []
for i in CorrKoef:
    for j in CorrKoef.index[CorrKoef[i] > 0.9]:
        if i <> j and j not in CorField and i not in CorField:
            CorField.append(j)
            print "%s-->%s: r^2=%f" % (i,j, CorrKoef[i][CorrKoef.index==j].values[0])

На выходе получим следующие:

fact_close_date-->status_13: r^2=0.997362
ttl_delq_5_29-->ttl_delq_30: r^2=0.954740
ttl_delq_5_29-->pmt_string_84m_A: r^2=0.925870
ttl_delq_30_59-->pmt_string_84m_2: r^2=0.903337
ttl_delq_90_plus-->pmt_string_84m_5: r^2=0.978239
delq_balance-->max_delq_balance: r^2=0.986967
pmt_freq_3-->relationship_1: r^2=0.909820
pmt_freq_3-->currency_RUB: r^2=0.910620
pmt_freq_3-->count_credit: r^2=0.911109

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

FieldDrop =FieldDrop + ['fact_close_date','ttl_delq_30',
                        'pmt_string_84m_5',
                        'pmt_string_84m_A',
                        'pmt_string_84m_A',
                        'max_delq_balance',
                        'relationship_1',
                        'currency_RUB',
                        'count_credit']
newtr = trainDF.drop(FieldDrop, axis=1)

Построение и выбор модели

Ну что же первичные данные обработаны и теперь можно перейти к построению модели.
Отделим признак класса от обучающей выборки:

target = newtr.bad.values
train = newtr.drop('bad', axis=1).values

Теперь давайте уменьшим размерность нашей выборки, дабы взять только значимые параметры. Для этого воспользуемся методом главных компонент и его реализацией PCA() в модуле sklearn. В параметре мы передаем количество компонент, которые мы хотим сохранить(я выбрал 20, т.к. при них результаты моделей практически не отличались от результатов по исходным данным)

coder = PCA(n_components=20)
train = coder.fit_transform(train)

Пришло время для определения моделей классификации. Возьмем несколько различных алгоритмов и сравним результаты их работы при помощи характеристики Area Under ROC Curve (auc). Для моделирования будут рассмотрены следующие алгоритмы:

models = []
models.append(RandomForestClassifier(n_estimators=165, max_depth=4, criterion='entropy'))
models.append(GradientBoostingClassifier(max_depth =4))
models.append(KNeighborsClassifier(n_neighbors=20))
models.append(GaussianNB())

Итак модели выбраны. Давайте сейчас разобьем нашу обучающую выборку на 2 подвыборки: тестовую и обучающую. Данное действие нужно чтобы мы могли посчитать характеристику auc для наших моделей. Разбиение можно провести функцией train_test_split() из модуля sklearn:

TRNtrain, TRNtest, TARtrain, TARtest = train_test_split(train, target, test_size=0.3, random_state=0)

Осталось осталось обучить наши модели и оценить результат.
Для расчета характеристики auc есть 2 пути:

  1. Стандартными средствами модуля sklearn при помощи функции roc_auc_score или auc
  2. С помощью стороннего пакета ml_metrics и функции auc()

Я воспользуюсь вторым способом, т.к. первый был показан в предыдущей статье. Пакет ml_metrics является очень полезным дополнением к sklearn, т.к. в нем присутствуют некоторые метрики, которых нет в sklearn.
Итак, построим ROC кривые и посчитаем их площади:

plt.figure(figsize=(10, 10)) 
for model in models:
    model.fit(TRNtrain, TARtrain)
    pred_scr = model.predict_proba(TRNtest)[:, 1]
    fpr, tpr, thresholds = roc_curve(TARtest, pred_scr)
    roc_auc = ml_metrics.auc(TARtest, pred_scr)
    md = str(model)
    md = md[:md.find('(')]
    pl.plot(fpr, tpr, label='ROC fold %s (auc = %0.2f)' % (md, roc_auc))

pl.plot([0, 1], [0, 1], '--', color=(0.6, 0.6, 0.6))
pl.xlim([0, 1])
pl.ylim([0, 1])
pl.xlabel('False Positive Rate')
pl.ylabel('True Positive Rate')
pl.title('Receiver operating characteristic example')
pl.legend(loc="lower right")
pl.show()

image
Итак, по результатам анализа наших моделей можно сказать, что лучше всего себя показал градиентный бустинг, его точность порядка 69%. Соответственно для обучения тестовой выборки мы выберем его. Давайте заполним информацию в тестовой выборке, предварительно обработав ее до нужного формата:

#приводим тестовую выборку к нужному формату
FieldDrop.append('bad')
test = testDF.drop(FieldDrop, axis=1).values
test = coder.fit_transform(test)
#обучаем модель
model = models[1]
model.fit(train, target)
#записываем результат
testDF.bad = model.predict(test)

Заключение

В качестве заключения хотелось бы отметить, что полученная точность модели в 69%, является не достаточно хорошей, но большей точности я добиться не смог. Хотелось бы отметить, тот факт, что при построении модели по полной размерности, т.е. без учета коррелируемых столбцов и сокращения размерности, она дала так же 69% точности (это можно легко проверить используя набор trainDF для обучения модели)
В данной статье, я постарался показать все основные этапы анализа данных от первичной обработки сырых данных до построения модели классификатора. Кроме того, хотелось бы отметить, что в анализируемые модели не был включен метод опорных векторов, это связано с тем, что после нормализации данных точность модели опустилась до 51% и лучший результат который мне удалось получить с ним был в районе 60%, при значительных затратах по времени.
Также хотелось бы отметить, что, к сожалению на тестовой выборке результат проверить не удалось, т.к. не уложился в сроки проведения турнира.

Автор: kuznetsovin

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js