Back to Home

An example of solving a credit scoring problem using the python + pandas + scikit-learn bundle

python · pandas · scikit-learn

An example of solving a credit scoring problem using the python + pandas + scikit-learn bundle

    Introduction


    Good afternoon, dear readers.
    Recently, wandering around the expanses of the global web, I came across a tournament that was held by the TCS bank earlier this year. After reviewing the tasks, I decided to test my skills in analyzing data on them.
    I decided to start the verification with the scoring problem (Task number 3). To solve it, I, as always, used Python with the pandas and scikit-learn analytic modules .

    Description of the data and statement of the problem


    The Bank requests the credit history of the applicant from the three largest Russian credit bureaus. A selection of Bank customers is provided in the SAMPLE_CUSTOMERS.CSV file . The sample is divided into parts "train" and "test". From the “train” sample, the value of the bad target variable is known - the presence of "default" (the client assumes a delay of 90 days or more during the first year of using the loan). The file SAMPLE_ACCOUNTS.CSV provides data from the responses of credit bureaus to all requests for relevant customers. SAMPLE_CUSTOMERS
    data format - information about the possibility of default of a particular person.
    Description of SAMPLE_ACCOUNTS dataset format :
    Set Description
    NameDescription
    TCS_CUSTOMER_IDCustomer id
    BUREAU_CDCode of the bureau from which the invoice was received
    BKI_REQUEST_DATEDate on which the request was made to the bureau
    CurrencyContract currency (ISO currency letter code)
    RELATIONSHIPType of relationship to contract
    1 - Individual
    2 - Additional card / Authorized user
    4 - Joint
    5 - Guarantor
    9 - Legal entity
    OPEN_DATEContract opening date
    FINAL_PMT_DATEFinal payment date (planned)
    TypeContract Type Code
    1 - Car loan
    4 - Leasing
    6 - Mortgage
    7 - Credit Card
    9 - Consumer loan
    10 - Business Development Credit
    11 - Loan for replenishment of working capital
    12 - Loan for the purchase of equipment
    13 - Real estate construction loan
    14 - Credit for the purchase of shares (for example, margin lending)
    99 - Other
    PMT_STRING_84MDiscipline (timeliness) of payments. The line is composed of account status codes at the moments when the bank transmits data on the account to the bureau, the first character is the status as of the date PMT_STRING_START, then sequentially in descending order of dates.
    0 - New, no rating possible
    X - No information
    1 - Payment without delay
    A - Delay from 1 to 29 days
    2 - Delay from 30 to 59 days
    3 - Delay from 60 to 89 days
    4 - Delay from 90 to 119 days
    5 - Delay more than 120 days
    7 - Regular consolidated payments
    8 - Repayment of the loan using collateral
    9 - Bad debt / transferred to collection / missed payment
    StatusContract status
    00 - Active
    12 - Paid by security
    13 - Account closed
    14 - Transferred to another bank for servicing
    21 - Dispute
    52 - Expired
    61 - Return Issues
    OUTSTANDINGRemaining outstanding debt. Amount in rubles at the rate of the Central Bank of the Russian Federation
    NEXT_PMT
    The size of the next payment. Amount in rubles at the rate of the Central Bank of the Russian Federation
    INF_CONFIRM_DATEDate of confirmation of account information
    FACT_CLOSE_DATE
    Account Closing Date (Actual)
    TTL_DELQ_5
    The number of delays up to 5 days
    TTL_DELQ_5_29
    The number of delays from 5 to 29 days
    TTL_DELQ_30_59
    The number of delays from 30 to 59 days
    TTL_DELQ_60_89
    The number of delays from 60 to 89 days
    TTL_DELQ_30
    The number of delays up to 30 days
    TTL_DELQ_90_PLUS
    The number of delays 90+ days
    PMT_FREQ
    Payment Frequency Code
     1 - Weekly
    2 - Once every two weeks
    3 - Monthly
    A - Once every 2 months
    4 - Quarterly
    B - Once every 4 months
    5 - Once every six months
    6 - Annually
    7 - Other
    CREDIT_LIMIT
    Credit limit. Amount in rubles at the rate of the Central Bank of the Russian Federation
    DELQ_BALANCE
    Current arrears. Amount in rubles at the rate of the Central Bank of the Russian Federation
    MAX_DELQ_BALANCE
    The maximum amount of arrears. Amount in rubles at the rate of the Central Bank of the Russian Federation
    CURRENT_DELQ
    Current number of days past due
    PMT_STRING_START
    Line Start Date PMT_STRING_84M
    INTEREST_RATE
    Loan interest rate
    CURR_BALANCE_AMT
    The total amount paid, including the principal amount, interest, penalties and fines. Amount in rubles at the rate of the Central Bank of the Russian Federation



    The task is to build a model on the “train” sample that determines the probability of “default” and put down its probabilities for customers from the “test” sample . To evaluate the model, the Area Under ROC Curve characteristic will be used (also indicated in the task conditions).

    Data preprocessing


    To get started, download the source files and look at them:
    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_idbadsample_type
    0 1NaN test
    1 2 0 train
    2 3 1 train
    3 4 0 train
    4 5 0 train


    From the conditions of the task, we can assume that the SampleAccounts set contains several records for one borrower, let's check this:

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

    Our assumption was correct. Unique borrowers 50,000 of 280,942 records. This is due to the fact that one borrower has several loans and for each of them different information in different bureaus. Therefore, you need to perform conversions on SampleAccounts so that one line corresponds to one borrower.
    Now let's get a list of all unique loans for each borrower:

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

    Therefore, when we received the list of loans, we can display some general information for each element of the list. Those. it would be possible to take a bunch of the fields listed above and make it an index, against which we would perform further manipulations, but, unfortunately, one unpleasant moment lies in wait for us. It consists in the fact that the 'final_pmt_date' field in the dataset has empty values. Let's try to get rid of them.
    We have in the set the field the actual closing date of the loan, therefore, if it is, and the field 'final_pmt_date' is not filled, then you can write this value into it. For the rest, just write 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)
    

    Now that we have got rid of empty values, let's get the latest date for contacting any of the bureaus for each of the loans. This is useful to us for determining its attributes, such as contract status, type, etc.

    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_idopen_datefinal_pmt_datecredit_limitcurrency
    1392613962919421 RUB 39924
    395053987030000 RUB 39862
    396444004211858 RUB 40043
    3987641701300,000RUB 40766
    399424030819691 RUB 40435
    4042142247169000RUB 40756
    404285138610,000 RUB 40758
    406764104028967 RUB 40764
    240472406187551 RUB 40661
    406524095821186 RUB 40661
    3396474006822694 RUB 40069
    406040 20000 RUB 40624
    4385524037875,000 RUB 40479
    39493397975000 RUB 39823
    39759401236023 RUB 40125

    Now add the dates we received to the main set:

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

    So, further we will break the columns in which the parameters are strictly defined, so that each value from these fields has a separate column. By condition, the columns with the given values ​​will be:
    • pmt_string_84m
    • pmt_freq
    • type
    • status
    • relationship
    • bureau_cd

    The code to convert them is shown below:

    # преобразуем 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)
    

    The next step is to convert the field 'fact_close_date', which contains the date of the last actual payment, so that it contains only 2 values:
    • 0 - there was no last payment
    • 1 - the last payment was

    I made this replacement because initially the field was half full.

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

    Now we need to get the latest data on all loans from our data set. The "inf_confirm_date_max" field obtained above will help us in this . We added the deadline for updating credit information in all bureaus to it:

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

    After the above actions, our sample was significantly reduced, but now we need to summarize all the information on the loan and the borrower obtained earlier. To do this, we will group our data set:

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

    Our data is almost ready to start the analysis. It remains to perform a few more actions:
    1. Remove unnecessary columns
    2. Bring all credit limits in rubles
    3. Calculate how many loans each borrower according to information from the bureau

    Let's start by clearing the table of unnecessary columns:

    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)
    

    Next, we transfer all credit limits to rubles. For simplicity, I took the current exchange rates. Although it would probably be more correct to take a course at the time of opening the account. Another nuance is that for analysis we need to remove the “Сurrency” text field , therefore, after converting currencies into rubles, we will manipulate this field with the fields above:

    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)
    

    So, before the final grouping, we will add a field filled with units to our set. Those. when we do the last grouping, the amount on it will give the number of loans from the borrower:

    PreFinalDS['count_credit'] = 1

    Now that we have all the data in the data set quantitative, you can fill in the gaps in the data 0 and perform the final grouping by customer:
    PreFinalDS.fillna(0, inplace=True)
    FinalDF = PreFinalDS.groupby('tcs_customer_id').sum()
    FinalDF
    


    Preliminary analysis



    Well, the primary data processing is completed and you can begin to analyze them. To begin, we will divide our data into training and test samples. The column “sample_type” from SampleCustomers will help us with this , just such a separation has been made.
    In order to break our processed DataFrame, just combine it with SampleCustomers to play with filters:

    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)
    

    Next, let's see how the signs correlate with each other, for this we construct a matrix with the correlation coefficients of the signs. With pandas, this can be done with one command:

    CorrKoef = trainDF.corr()

    After the action above, CorrKoef will contain a 61x61 size matrix.
    Rows and columns of it will be the corresponding field names, and at their intersection - the value of the correlation coefficient. For instance:
    fact_close_date
    status_130.997362

    A case is possible when there is no correlation coefficient. This means that these fields are most likely filled with only one identical value and can be omitted during analysis. Check:

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

    At the output, we got a list of fields that can be deleted:
    • pmt_string_84m_6
    • pmt_string_84m_8
    • pmt_freq_5
    • pmt_freq_A
    • pmt_freq_B
    • status_12

    The next step is to find the fields that correlate with each other (for which the correlation coefficient is more than 90%) using our matrix:

    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)
    


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



    Well, what is the primary data processed and now we can proceed to the construction of the model.
    Separate the class attribute from the training set:

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

    Now let's reduce the dimension of our sample in order to take only significant parameters. To do this, we use the principal component method and its implementation of PCA () in the sklearn module . In the parameter we pass the number of components that we want to save (I chose 20, because with them the results of the models practically did not differ from the results according to the initial data)

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

    The time has come to define classification models. Let's take a few different algorithms and compare the results of their work using the characteristics of Area Under ROC Curve ( auc ). For modeling, the following algorithms will be considered:


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

    So the models are selected. Let's split our training sample into 2 subsamples: test and training. This action is necessary so that we can calculate the auc characteristic for our models. The splitting can be done with the train_test_split () function from the sklearn module :

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

    It remains to train our models and evaluate the result.
    There are 2 ways to calculate auc characteristics :
    1. By standard means of the sklearn module using the roc_auc_score or auc function
    2. Using the third-party ml_metrics package and the auc () function

    I will use the second method, because The first was shown in a previous article. The ml_metrics package is a very useful addition to sklearn, as it contains some metrics that are not in sklearn.
    So, we will build ROC curves and calculate their areas:

    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
    So, according to the results of the analysis of our models, we can say that gradient boosting showed its best, its accuracy is about 69%. Accordingly, for training a test sample, we will choose it. Let's fill in the information in the test sample, pre-processing it to the desired format:

    #приводим тестовую выборку к нужному формату
    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)
    

    Conclusion


    In conclusion, I would like to note that the obtained model accuracy of 69% is not good enough, but I could not achieve more accuracy. I would like to note the fact that when building the model in full dimension, i.e. excluding correlated columns and dimensionality reduction, it also gave 69% accuracy (this can be easily checked using the trainDF kit for model training)
    In this article, I tried to show all the main stages of data analysis from primary processing of raw data to building a classifier model. In addition, I would like to note that the support vector method was not included in the analyzed models, this is due to the fact that after normalizing the data, the accuracy of the model dropped to 51% and the best result that I managed to get with it was around 60%, with significant time cost.
    I would also like to note that, unfortunately, the test sample failed to verify the result, because did not meet the dates of the tournament.

    Read Next