Решение задачи кредитного скоринга на примере клиентов ТКС

Введение

Добрый день, уважаемые читатели.

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

Начать проверку я решил с задачи о скоринге (Задание №3). Для ее решения я, как всегда, использовал Python с аналитическими модулями pandas и scikit-learn.

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

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

Формат данных SAMPLE_CUSTOMERS – информация о счетах человека, передаваемая другими банками в данное бюро.

Описание формата набора данных SAMPLE_ACCOUNTS:

Задача состоит в том, чтобы на выборке «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 =',')
SampleAccounts
SampleCustomers.head()

Теперь давайте объеденим наши наборы данных, для этого добавим данные из SampleCustomers к SampleAccounts: UnionDF = SampleAccounts.merge(SampleCustomers, on = [’tcs_customer_id’]) Из условий задачи можно предположить, что набор SampleAccounts содержит несколько записей по одному заемщику давайте проверим это:

"%s from %s" % (SampleAccounts.tcs_customer_id.drop_duplicates().count(), SampleAccounts.tcs_customer_id.count())
'50000 from 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)

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

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
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 значения:

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()

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

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

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

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

FieldDrop = [i for i in CorrKoef if CorrKoef[i].isnull().drop_duplicates().values[0]]
FieldDrop
['pmt_string_84m_6',
 'pmt_string_84m_8',
 'pmt_freq_5',
 'pmt_freq_A',
 'pmt_freq_B',
 'status_12']

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

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 пути:

Я воспользуюсь вторым способом, т.к. первый был показан в предыдущей статье. Пакет 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()

png

Итак, по результатам анализа наших моделей можно сказать, что лучше всего себя показал градиентный бустинг, его точность порядка 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%, при значительных затратах по времени.

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

 
comments powered by Disqus