Give me some credit--申请评分卡模型

目标及背景

信用评分算法,对默认可能性进行猜测,这是银行用来判断贷款是否应该被授予的方法,完成一个评分卡,通过预测某人在未来两年将会经历财务危机的可能性来提高信用评分的效果,帮助贷款人做出最好的决策

本项目主要为申请者评分模型的开发过程。

数据来源:数据来自Kaggle,cs-training.csv是有15万条的样本数据,下图可以看到这份数据的大致情况。下载地址为:https://www.kaggle.com/c/GiveMeSomeCredit/data

定义观察时间窗口

对于违约的时长的定义(观察时间窗口)可以简单的使用基本的业务逻辑来定义,也可以通过一定 的统计方法来确定,一般而言为了避免偏差会使用使用统计方法作为验证,这里我们通过查看历史上违 约的情况分析定义什么样的情况下才定义为违约比较合适。 取订单审批日 (APPLY_DT) 在 2015 年 1 月 1 日之后的 2017 年 10 月 31 日之前的所有订单号,取所有订单号对应的所有的逾期详情的记录(最后的截至时间为 201805):

import numpy as np
import pandas as pd
%matplotlib inline
# 读取数据
data_sw = pd.read_csv('CreditSampleWindow.csv', encoding='utf-8')
# 了解数据
data_sw.head()
data_sw.shape  # (1813157, 5)
data_sw.info()
# 查看缺失值
data_sw.isnull().mean()  # 除CID字段外,其余字段缺失值在0.082451
# 去除重复的记录
data_sw.drop_duplicates(inplace=True)
data_sw.shape  # (1813157, 5)

取每个 ID 每个月份内的最高逾期记录作为该月份的逾期指标,记录中只有从低阶段到高阶段的记录,因此用 _AFT 字段作为该月的指标。

data_sw['START_MONTH'] = data_sw['START_DATE'].apply(lambda x: int(x // 100))
data_sw['CLOSE_MONTH'] = data_sw['CLOSE_DATE'].apply(lambda x: int(x // 100))
data_sw['AFT_FLAG'] = data_sw['STAGE_AFT'].apply(lambda x: int(x[-1]))
data_sw.head()
# 观察数据发现,CLOSE_DATE部分记录为0,将CLOSE_DATE为0的数据填补为 201805(数据截止日期为201805)
data_sw.loc[data_sw['CLOSE_MONTH'] == 0, ['CLOSE_MONTH']] = 201805

# 生成单个订单的流水,时间跨度为200003--200305
# 提取ID、月份、对应的状态作为新的数据
overdue = data_sw.loc[:, ['CID', 'START_MONTH', 'AFT_FLAG']].rename(columns={'START_MONTH':'CLOSE_MONTH'}).append(data_sw.loc[:, ['CID', 'CLOSE_MONTH', 'AFT_FLAG']], ignore_index=True)
# 生成每个订单逾期信息表
overdue = overdue.sort_values(by=['CID', 'CLOSE_MONTH', 'AFT_FLAG']).drop_duplicates(subset=['CID', 'CLOSE_MONTH'], keep='last').set_index(['CID', 'CLOSE_MONTH']).unstack(1)
overdue.columns = overdue.columns.droplevel()

构建转移矩阵,索引表示转移前的逾期状态,列名表示转移后的逾期状态

import collections
def get_mat(df):
    '''
    构建转移矩阵,索引表示转移前的逾期状态,
    列名表示转移后的逾期状态
    '''
    trans_mat = pd.DataFrame(data=0, columns=range(0, 10), index=range(0, 10))  # 创建一个10x10的全为0的矩阵
    counter = collections.Counter()  # 快速的计数
    for i, j in zip(df.columns, df.columns[1:]):
        select = (df[i].notnull()) & (df[j].notnull())  # 提取同一条记录中相邻两个字段中都非空的记录
        counter += collections.Counter(tuple(x) for x in df.loc[select, [i, j]].values)
        
    for key in counter.keys():
        trans_mat.loc[key[0], key[1]] = counter[key]
    trans_mat['all_count'] = trans_mat.apply(sum, axis=1)
    bad_count = []
    for j in range(10):
        bad_count.append(trans_mat.iloc[j, j+1:10].sum())
    trans_mat['bad_count'] = bad_count
    trans_mat['to_bad'] = trans_mat['bad_count'] / trans_mat['all_count']
    return trans_mat
get_mat(overdue)

由上表可知,在逾期阶段到了 M2 时,下一阶段继续转坏的概率达到了 67%,逾期阶段到达 M3 阶 段时,下一阶段继续转坏的概率为 86%,可根据业务需要(营销、风险等等)来考虑定义进入 M2 或 M3 阶段的用户为坏客户。这里我们暂定为 M2。

定义表现时间窗口

在开发信用风险评分卡模型时,需要选择客户违约处于稳定状态的时间点来作为最优表现时间窗 口,这样既可以最大限度地降低模型的不稳定性,也可以避免低估最终的违约样本的比率。通过逾期表 的数据来统计推断最合适的表现窗口。

# 读取订单首次动用日期信息表
data_fu = pd.read_csv('CreditFirstUse.csv', encoding='utf-8')
data_fu.set_index(['CID'], inplace=True)
data_fu.head()
data_fu.shape  # (388259, 1)
data_fu['FST_USE_MONTH'] = data_fu['FST_USE_DT'].apply(lambda x: int(x//100))

# 计算每笔订单第一次出现逾期的月份索引的位置
def get_first_overdue(ser):
    array = np.where((ser >= 2) == True)[0]
    if array.size > 0:
        return array[0]
    else:
        return np.nan

overdue_index = overdue.apply(get_first_overdue, axis=1)

data_fu['OVERDUE_INDEX'] = overdue_index

data_fu['START_INDEX'] = data_fu['FST_USE_MONTH'].map({k:v for v, k in enumerate(overdue.columns)})

data_fu.loc[data_fu['OVERDUE_INDEX'].notnull()].head()

# 查看异常数据,即逾期时间早于开始时间的
data_fu[data_fu['OVERDUE_INDEX'] < data_fu['START_INDEX']]
# 查看该订单记录
data_sw[data_sw['CID'] == 'CID0164451']
month_count = (data_fu.OVERDUE_INDEX - data_fu.START_INDEX).value_counts().sort_index()[1:]
month_count.plot();
month_count.cumsum().plot();

此处能看出来,该产品的定义逾期较适合定义在首 15 期之后。

正负样本定义实例:如果通过分析判断观察时间窗口为 m2 表现时间窗口为 6 个月,那么则定义首 六期内有 m2 及以上逾期,即逾期天数 >= 31 天的样本为负样本,逾期天数 <= 3 天的样本为正样本,中 间状态的样本做不确定处理不进入模型

1. 数据准备及数据预处理

# 读取数据
data_train = pd.read_csv('/Users/henrywongo/Desktop/Code_Python/风控/data/cs-training.csv', index_col=0)
# 了解数据
data_train.shape  # (150000, 11)
data_train.describe().T
data_train.info()
# 查看缺失值
data_train.isnull().mean()

有两个字段有缺失值:'MonthlyIncome', 'NumberOfDependents '

# 删除完全重复的记录
data_train.drop_duplicates(inplace=True)
data_train.shape  # (149391, 11)

1.1 缺失值处理

import missingno as msno
msno.matrix(data_train)

MonthlyIncome 的缺失比例约为 20%,这里我们基于『一个人的收入有较大的可能性和其自身的 其他个人特征有关联』这样的假设,使用随机森林算法进行缺失值填补。

from sklearn.ensemble import RandomForestRegressor

def fill_missing(data, to_fill):
    df = data.copy()
    columns = [*df.columns]
    columns.remove(to_fill)
    
    # 移除有缺失值的列
    columns.remove('NumberOfDependents')
    X = df.loc[:, columns]
    y = df.loc[:, to_fill]
    X_train = X.loc[df[to_fill].notnull()]
    y_train = y.loc[df[to_fill].notnull()]
    X_pred = X.loc[df[to_fill].isnull()]
    rfr = RandomForestRegressor(random_state=22, n_estimators=200, max_depth=3, n_jobs=-1)
    rfr.fit(X_train, y_train)
    y_pred = rfr.predict(X_pred).round()
    df.loc[df[to_fill].isnull(), to_fill] = y_pred
    return df

'NumberOfDependents'缺失值较少,可以考虑直接删除

data_train = fill_missing(data_train, 'MonthlyIncome')
data_train.dropna(inplace=True)
data_train.shape  # (145563, 11)

1.2 异常值处理

data_train['age'].value_counts().sort_index()  # 发现有一条记录的年龄为0

age 字段中包含有为 0 的值,通常认为该值为异常 值,查看数据可以发现仅有一条数据年龄为 0,因此可以直接删除

data_train = data_train[data_train['age'] > 0]

import matplotlib.pyplot as plt
columns = ['NumberOfTime30-59DaysPastDueNotWorse',
          'NumberOfTime60-89DaysPastDueNotWorse',
          'NumberOfTimes90DaysLate']
data_train.loc[:, columns].plot.box(vert=False)

从业务上考虑,不应当出现这样的高的次数,这里同样删除掉这些异常数据

for col in columns:
    data_train = data_train.loc[data_train[col] < 90]

1.3 划分训练集和验证集

from sklearn.model_selection import train_test_split
X = data_train.iloc[:, 1:]
y = data_train.iloc[:, 0]
X_train, X_vali, y_train, y_vali = train_test_split(X, y, test_size=0.3, random_state=0)
df_train = pd.concat([y_train, X_train], axis=1)
df_vali = pd.concat([y_vali, X_vali], axis=1)

1.4 探索性数据分析

# 查看数据分布
df_train.describe().T
df_train['SeriousDlqin2yrs'].value_counts()

0 94934
1 6813
Name: SeriousDlqin2yrs, dtype: int64

df_train['age'].plot.hist(bins=30);
# 可以看出收入有少部分人非常高,使用小于 99% 的分位数的数据查看收入分布
income = df_train['MonthlyIncome']
income.loc[income < 23334].plot.hist(bins=50)
# 变量之间的相关性
import seaborn as sns
corr = df_train.corr()
plt.subplots(figsize=(12, 12))
sns.heatmap(corr, annot=True, vmax=1, square=True, cmap='Blues')
plt.show()

2. 连续变量离散化

使用logistic 模型转换为标准评分卡的形式,这一环节是必须完成的。信用评 分卡开发中一般有常用的等距分段、等深分段、最优分段

# 根据woe以及IV值定义分箱函数
import numpy as np
import pandas as pd
import scipy

def auto_bin(DF, X, Y, n=5, iv=True, detail=False,q=20):
    """
    自动最优分箱函数,基于卡方检验的分箱

    参数:
    DF: DataFrame 数据框
    X: 需要分箱的列名
    Y: 分箱数据对应的标签 Y 列名
    n: 保留分箱个数
    iv: 是否输出执行过程中的 IV 值
    detail: 是否输出合并的细节信息
    q: 初始分箱的个数

    区间为前开后闭 (]

    返回值:

    """


    # DF = df_train
    # X = "age"
    # Y = "SeriousDlqin2yrs"

    DF = DF[[X,Y]].copy()

    # 按照等频对需要分箱的列进行分箱,cut为灯具分箱,qcut为等频分箱
    DF["qcut"],bins = pd.qcut(DF[X], retbins=True, q=q, duplicates="drop")
    # 统计每个分段 0,1的数量
    coount_y0 = DF.loc[DF[Y]==0].groupby(by="qcut")[Y].count()
    coount_y1 = DF.loc[DF[Y]==1].groupby(by="qcut")[Y].count()
    # num_bins值分别为每个区间的上界,下界,0的频次,1的频次
    num_bins = [*zip(bins,bins[1:],coount_y0,coount_y1)]

    # 定义计算 woe 的函数
    def get_woe(num_bins):
        # 通过 num_bins 数据计算 woe
        columns = ["min","max","count_0","count_1"]
        df = pd.DataFrame(num_bins,columns=columns)

        df["total"] = df.count_0 + df.count_1
        df["percentage"] = df.total / df.total.sum()
        df["bad_rate"] = df.count_1 / df.total
        df["woe"] = np.log((df.count_0/df.count_0.sum()) /
                           (df.count_1/df.count_1.sum()))
        return df

    # 创建计算 IV 值函数
    def get_iv(bins_df):
        rate = ((bins_df.count_0/bins_df.count_0.sum()) -
                (bins_df.count_1/bins_df.count_1.sum()))
        IV = np.sum(rate * bins_df.woe)
        return IV


    # 确保每个分组的数据都包含有 0 和 1
    for i in range(20): # 初始分组不会超过20
        # 如果是第一个组没有 0 或 1,向后合并
        if 0 in num_bins[0][2:]:
            num_bins[0:2] = [(
                num_bins[0][0],
                num_bins[1][1],
                num_bins[0][2]+num_bins[1][2],
                num_bins[0][3]+num_bins[1][3])]
            continue

        # 其他组出现没有 0 或 1,向前合并
        for i in range(len(num_bins)):
            if 0 in num_bins[i][2:]:
                num_bins[i-1:i+1] = [(
                    num_bins[i-1][0],
                    num_bins[i][1],
                    num_bins[i-1][2]+num_bins[i][2],
                    num_bins[i-1][3]+num_bins[i][3])]
                break
        # 循环结束都没有出现则提前结束外圈循环
        else:
            break

    # 重复执行循环至分箱保留 n 组:
    while len(num_bins) > n:
        # 获取 num_bins 两两之间的卡方检验的置信度(或卡方值)
        pvs = []
        for i in range(len(num_bins)-1):
            x1 = num_bins[i][2:]
            x2 = num_bins[i+1][2:]
            # 0 返回 chi2 值,1 返回 p 值。
            pv = scipy.stats.chi2_contingency([x1,x2])[1]
            # chi2 = scipy.stats.chi2_contingency([x1,x2])[0]
            pvs.append(pv)

        # 通过 p 值进行处理。合并 p 值最大的两组
        i = pvs.index(max(pvs))
        num_bins[i:i+2] = [(
            num_bins[i][0],
            num_bins[i+1][1],
            num_bins[i][2]+num_bins[i+1][2],
            num_bins[i][3]+num_bins[i+1][3])]

        # 打印合并后的分箱信息
        bins_df = get_woe(num_bins)
        if iv:
            print(f"{X} 分{len(num_bins):2}组 IV 值: ",get_iv(bins_df))
        if detail:
            print(bins_df)
    print("\n".join(map(lambda x:f"{x:.16f}",pvs)))
    # 返回分组后的信息
    return get_woe(num_bins)#, get_iv(bins_df)
# 对每一个分组进行分析,选择合适的分箱数
df_train.columns

# 不能使用自动分享的变量
hand_bins = {"NumberOfTime30-59DaysPastDueNotWorse":[0,1,2,13],
             "NumberOfTimes90DaysLate":[0,1,2,17],
             "NumberRealEstateLoansOrLines":[0,1,2,4,54],
             "NumberOfTime60-89DaysPastDueNotWorse":[0,1,2,8]}

# 手动额按成分享数量的添加
auto_col_bins = {"RevolvingUtilizationOfUnsecuredLines":5,
                 "age":5,
                 "DebtRatio":5,
                 "MonthlyIncome":6,
                 "NumberOfOpenCreditLinesAndLoans":4,
                 "NumberOfDependents":3}

# 保证区间覆盖使用 np.inf 替换最大值 -np.inf 替换最小值
hand_bins = {k:[-np.inf,*v[1:-1],np.inf] for k,v in hand_bins.items()}

# 用于确定最优分箱的个数和区间 
age_bins_df = auto_bin(df_train, "age", "SeriousDlqin2yrs", n=5, iv=True,detail=False,q=20)
age_bins_df
# 用来保存每个分组的分箱数据 
bins_of_col = {}
# 生成自动分箱的分箱区间和分箱后的 IV 值 
for col in auto_col_bins:
    print(col)
    bins_df = auto_bin(df_train, col, "SeriousDlqin2yrs", n=auto_col_bins[col], iv=False, detail=False, q=20) 
    bins_list = list(sorted(set(bins_df["min"]).union(bins_df["max"])))
    # 保证区间覆盖使用 np.inf 替换最大值 -np.inf 替换最小值
    bins_list[0], bins_list[-1] = -np.inf, np.inf
    bins_of_col[col] = bins_list
bins_of_col
# 合并手动分箱数据 
bins_of_col.update(hand_bins)

3. 变量筛选

# 计算分箱数据的 IV 值
def get_iv(df,col,y,bins):
    df = df[[col,y]].copy()
    df["cut"] = pd.cut(df[col],bins)
    bins_df = df.groupby("cut")[y].value_counts().unstack()
    bins_df["woe"] = np.log((bins_df[0] / bins_df[0].sum()) /
                     (bins_df[1] / bins_df[1].sum()))
    iv = np.sum((bins_df[0] / bins_df[0].sum() -
          bins_df[1] / bins_df[1].sum())*bins_df.woe)
    return iv ,bins_df

# 保存 IV 值信息 
info_values = {}
# 保存 woe 信息 
woe_values = {}
for col in bins_of_col:
    iv_woe = get_iv(df_train, col, "SeriousDlqin2yrs", bins_of_col[col])
    info_values[col], woe_values[col] = iv_woe
info_values
def plt_iv(info_values):
    keys,values = zip(*info_values.items())
    nums = range(len(keys)) 
    plt.barh(nums,values) 
    plt.yticks(nums,keys)
    for i, v in enumerate(values):
        plt.text(v, i-.2, f"{v:.2f}")
plt_iv(info_values)

可以看出 NumberRealEstateLoansOrLines 和 NumberOfDependents 变量的 IV 值明显较低,所 以予以删除。DebtRatio、MonthlyIncome、NumberOfOpenCreditLinesAndLoans 等变量可以考虑删 除也可以予以保留。

4. 构建模型

在建立模型之前,我们需要将筛选后的变量转换为 WoE 值,便于信用评分

4.1 WOE转换

通过生成的分箱和 WOE 数据

model_woe = pd.DataFrame(index=df_train.index)
for col in bins_of_col:
    model_woe[col] = pd.cut(df_train[col],bins_of_col[col]).map(woe_values[col]["woe"])
model_woe["SeriousDlqin2yrs"] = df_train["SeriousDlqin2yrs"]
model_woe.to_csv('WoeData.csv',encoding="utf8", index=False)

4.2 Logistic模型建立

# 直接调用statsmodels包来实现逻辑回归
import statsmodels.api as sm

data = pd.read_csv('WoeData.csv', encoding='utf-8')
# 设置因变量
endog = data['SeriousDlqin2yrs']
X = data.drop(["SeriousDlqin2yrs",
               "NumberRealEstateLoansOrLines",
               "NumberOfDependents"],axis=1)
# 设置自变量
exog = sm.add_constant(X)
logit = sm.Logit(endog,exog)
result = logit.fit()
result.summary()

各变量都已通过显著性检验,满足要求。

4.3 模型检验

vali_woe = pd.DataFrame(index=df_vali.index)
for col in bins_of_col:
    vali_woe[col] = pd.cut(df_vali[col],bins_of_col[col]).map(woe_values[col]["woe"])
vali_woe["SeriousDlqin2yrs"] = df_vali["SeriousDlqin2yrs"]
vali_Y = vali_woe['SeriousDlqin2yrs']
vali_X = vali_woe.drop(["SeriousDlqin2yrs",
                        "NumberRealEstateLoansOrLines",
                        "NumberOfDependents"],axis=1)
vali_exog = sm.add_constant(vali_X)
vali_proba = result.predict(vali_exog)

import scikitplot as skplt
# 预测结果为对应 1 的概率,转换为数组用于绘图 
vali_proba_df = pd.DataFrame(vali_proba,columns=[1]) 
vali_proba_df.insert(0,0,1-vali_proba_df)
skplt.metrics.plot_roc(vali_Y,
                       vali_proba_df,
                       plot_micro=False, plot_macro=False);

5. 信用评分

B = 20/np.log(2)
A = 600 + B*np.log(1/60)
A,B  # (481.8621880878296, 28.85390081777927)
result.params
base_score = A - B*result.params["const"]
base_score

557.3566070296174

# 将评分卡写入文件
file = "ScoreData.csv"
with open(file,"w") as fdata: 
    fdata.write(f"base_score,{base_score}\n")
for col in result.params.index[1:]:
    score = woe_values[col]["woe"] * (-B*result.params[col]) score.name = "Score"
    score.index.name = col score.to_csv(file,header=True,mode="a")

你可能感兴趣的:(Give me some credit--申请评分卡模型)