基于Python的贷款逾期预测与信用评分卡建立

简介

项目介绍: 使用 Kaggle 的 GiveMeSomeCredit 数据集, 建立逻辑回归模型与评分卡, 对新申请信用贷款的客户进行违约预测与信用评分,降低贷款用户违约风险。
负责内容:1. 基于业务理解,使用 python 结合 Matplotlib、 Seaborn 库对各变量进行统计分析与可视化,完成数据异常、缺失、重复值清洗。

  1. 使用 python 实现 10 个特征变量的卡方分箱并得到对应分箱 WOE 及 VI 值, 根据特征重要性最终筛选出 5 个进入模型的特征变量。
  2. 使用过采样 SMOTE 方法处理数据不平衡问题,并建立逻辑回归模型, 使用 AUC 与 KS 值判断模型精确度, 并预测测试集每条记录的客户标签。
  3. 设置信用评分计算逻辑, 对测试集每位用户进行信用打分。 拒绝低分用户贷款申请, 有效降低“坏客户” 风险。

一、业务理解

1. 研究背景

银行或其他金融贷款机构在信贷业务中扮演重要角色,它会对贷款人或组织的贷款申请进行审批。这种审批决策建立在对贷款人未来财务状况的评估。信用评分卡常被运用于这种场景,帮助它们做出最好的决策。信用评分是指根据贷款客户的各种历史信用资料,利用一定的信用评分模型,得到不同等级的信用分数,根据客户的信用分数,授信者可以通过分析客户按时还款的可能性,据此决定是否给予授信以及授信的额度和利率。如果申请人的信用评分大于等于金融放款机构所设定的界限分数,此申请处于可接受的风险水平并将被批准;低于界限分数的申请人将被拒绝或给予标示以便进一步审查。

2. 研究目的与框架

本研究目的是建立逻辑回归模型和信用评分卡模型,对测试集中贷款人未来是否会逾期进行预测并打分,帮助金融机构更好的做审批决策。框架如下:

  • 业务理解,项目背景、研究目的及框架阐述。
  • 数据探索性分析,对样本进行描述性统计,并使用直方图、箱形图等描绘单变量分布形态,分析变量间相关性。
  • 数据预处理,结合EDA情况进行数据清洗,包括重复值、缺失值和异常值处理。
  • 分箱与变量选择,变量分箱,对应WOE和VI值计算,并筛选对违约状态影响最显著的指标进入模型。
  • 模型建立与评价,该步骤主要包括变量的WOE(证据权重)变换、样本不均衡处理、逻辑回归模型构建和模型评估四部分。
  • 测试集预测与信用评分卡建立,包括预测测试集用户违约情况并建立信用评分卡,最终对测试集用户进行打分。

二、数据探索性分析(EDA)

1. 数据来源

数据来源于Kaggle的GiveMeSomeCredit比赛,包括数据字典、训练集和测试集。其中训练集有15万条的样本数据,测试集有10万余条数据。

image.png

为方便后续处理,这里先将列名统一重命名为中文,并去除序号列。需要预测的是好坏客户这个变量,其他变量均为自变量。

column={'Unnamed: 0':'用户ID',
        'SeriousDlqin2yrs':'好坏客户',
        'RevolvingUtilizationOfUnsecuredLines':'可用额度比',
        'age':'年龄',
        'NumberOfTime30-59DaysPastDueNotWorse':'逾期30-59天笔数',
        'DebtRatio':'负债率',
        'MonthlyIncome':'月收入',
        'NumberOfOpenCreditLinesAndLoans':'信贷数量',
        'NumberOfTimes90DaysLate':'逾期90天笔数',
        'NumberRealEstateLoansOrLines':'固定资产贷款数量',
        'NumberOfTime60-89DaysPastDueNotWorse':'逾期60-89天笔数',
        'NumberOfDependents':'家属数量'}
data.rename(columns=column,inplace=True)
#去除训练集中用户ID列
data.drop(['用户ID'],axis=1,inplace=True)
image.png

下表为整理后的数据字典描述:

image.png

2. 数据描述性统计

image.png

上图可知,训练集数据有150000行,均为数值变量,且月收入和家属数量变量存在部分缺失。

image.png

其中月收入缺失约19.6%,家属数量缺失约2.6%。一般缺失值很少时可以选择删除缺失样本。另外缺失值也可以选择填充,包括根据样本间相似性填充、变量间相关关系填充和虚拟变量填充。最基本的填充法有均值、中位数、众数填充,也可以通过模型拟合等方法。具体填充方法后文数据预处理中详述。
缺失值处理方法参考 https://segmentfault.com/a/1190000015801384?utm_source=tag-newest

image.png

描述性统计发现,许多变量存在异常值,决定先对每个自变量进行单变量分析,观察其特征分布情况,方便特征工程选用合适方法对存在的异常值、缺失值进行处理。

3. 单变量分析

(1) 好坏客户
#EDA 客户类型
plt.figure(figsize=(8,5))
cus_class=data['好坏客户'].value_counts()
cus_class.plot('bar')
plt.title("好坏客户分布直方图")
plt.xlabel("好坏客户")
plt.ylabel("频数")
for x,y in zip(cus_class.index,cus_class):
    plt.text(x, y, '%.0f' % y, ha='center', va= 'bottom',fontsize=10)
image.png

上图中0为好客户,1为坏客户。由直方图可知,好客户有139974个,而坏客户仅10026个,约占6.6%,分布极其不平衡。不平衡数据会造成以总体分类准确率为学习目标的传统分类算法过多地关注多数类,使少数类样本的分类性能下降。故建立逻辑回归模型前需要进行过采样或欠采样处理。

(2) 年龄
plt.figure(figsize=(8,5))
data_test_0=data.loc[data.好坏客户 == 0]['年龄']
data_test_1=data.loc[data.好坏客户 == 1]['年龄']
sns.distplot(data_test_0.dropna(),color='g')
sns.distplot(data_test_1.dropna(),color='r')
plt.legend(['0','1'])
image.png

上图可以看出,年龄基本符合正态分布,且整体年龄小相较年龄大违约风险更大。为进一步分析各年龄段的违约情况,接下来将年龄进行分箱并计算违约率。

age=data.loc[data['年龄']>0,['年龄','好坏客户']]
age.loc[(data['年龄']<20),'年龄'] = '0-20'
age.loc[(data['年龄']>=20)&(data['年龄']<40),'年龄'] = '20-40'
age.loc[(data['年龄']>=40)&(data['年龄']<60),'年龄'] = '40-60' 
age.loc[(data['年龄']>=60)&(data['年龄']<80),'年龄'] = '60-80'
age.loc[(data['年龄']>=80),'年龄'] = '80+'
age_bad=age.groupby('年龄')['好坏客户'].sum()
age_total=age.groupby('年龄')['好坏客户'].count()
age_ratio=age_bad/age_total
age_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

上图可知,0-20岁缺少数据,整体分析结果基本与前面一致,客户层年龄越大,违约率越低。违约率最大的年龄组为20-40岁,最小的年龄组为80+岁。

(3) 月收入
a=data['月收入'].mean()+3*data['月收入'].std()
b=data['月收入'].mean()-3*data['月收入'].std()
plt.figure(figsize=(8,5))
data_test_0=data.loc[(data['月收入']<=a) & (data['月收入']>=b)][data.好坏客户 == 0]['月收入']
data_test_1=data.loc[(data['月收入']<=a) & (data['月收入']>=b)][data.好坏客户 == 1]['月收入']
sns.distplot(data_test_0.dropna(),color='g')
sns.distplot(data_test_1.dropna(),color='r')
plt.legend(['0','1'])
image.png

由于月收入被极值影响严重,故先排除3倍标准差的极值再画图。如图,月收入基本符合正态分布,满足统计分析前提。月收入主要集中在0-20000以内,但是也有少数极高收入的借款人。另外可以发现,月收入低相较月收入高的客户,违约风险更大。为进一步分析各收入段的违约情况,接下来将月收入进行分箱并计算违约率。

income=data.loc[data['月收入']>0,['月收入','好坏客户']]
income.loc[(data['月收入']<500),'月收入'] = '1'
income.loc[(data['月收入']>=500)&(data['月收入']<1000),'月收入'] = '2'
income.loc[(data['月收入']>=1000)&(data['月收入']<5000),'月收入'] = '3' 
income.loc[(data['月收入']>=5000)&(data['月收入']<10000),'月收入'] = '4'
income.loc[(data['月收入']>=10000)&(data['月收入']<20000),'月收入'] = '5'
income.loc[(data['月收入']>=20000),'月收入'] = '6'
income_bad=income.groupby('月收入')['好坏客户'].sum()
income_total=income.groupby('月收入')['好坏客户'].count()
income_ratio=income_bad/income_total
income_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

上图显示月收入在1000-5000的人群违约率最高,接着是收入在500-1000和5000-10000群体,违约比例均超出0.06。

(4) 可用额度比

对可用额度比整体进行画图,发现该变量极差很大,最大值达到50000以上。从业务逻辑出发,可用额度比值应该处于0-1之间,但根据散点图发现也有许多大于1的数字,异常值较多,猜测部分极大值可能是没有除以可用额度的值,是纯可贷款金额。由于这些异常值可能会影响分析,接下来分为<=1 和 >1 两个部分画图分析。

plt.figure(figsize=(8,5))
data_test_0=data.loc[data['可用额度比']<=1][data.好坏客户 == 0]['可用额度比']
data_test_1=data.loc[data['可用额度比']<=1][data.好坏客户 == 1]['可用额度比']
sns.distplot(data_test_0.dropna(),color='g')
sns.distplot(data_test_1.dropna(),color='r')
plt.legend(['0','1'])
image.png
plt.figure(figsize=(8,5))
data_test_0=data.loc[data['可用额度比']>1][data.好坏客户 == 0]['可用额度比']
data_test_1=data.loc[data['可用额度比']>1][data.好坏客户 == 1]['可用额度比']
sns.distplot(data_test_0.dropna(),color='g')
sns.distplot(data_test_1.dropna(),color='r')
plt.legend(['0','1'])
image.png

可以发现在可用额度比小于等于1的情况下,当可用额度比小于0.4时,违约概率较小;当其大于0.4时,可用额度比越大,违约概率越高;而当可用额度比大于1时,由于数值分布过于分散,看不出明显规律。下面将可用额度比再进行细化分箱。

bins=[0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0]
data['可用额度分箱']=pd.cut(data['可用额度比'],bins,right=True)
#违约率
revol_bad=data.groupby(['可用额度分箱'])['好坏客户'].sum()
revol_total=data.groupby(['可用额度分箱'])['好坏客户'].count()
revol_ratio=revol_bad/revol_total
revol_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

上图可看出可用额度比<=1时,违约率与可用额度比正相关。可用额度比越高违约率越大。

bins=[1,10,100,1000,10000,60000]
data['可用额度分箱']=pd.cut(data['可用额度比'],bins,right=True)
#违约率
revol_bad=data.groupby(['可用额度分箱'])['好坏客户'].sum()
revol_total=data.groupby(['可用额度分箱'])['好坏客户'].count()
revol_ratio=revol_bad/revol_total
revol_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

上图可看出可用额度比>1时,1-10和10-100两个区间的违约均大于0.3,大于可用额度比小于1的违约率。可用额度比[0,10]区间基本符合上述可用额度比越大,违约风险越大的分析。

a=data.loc[(data['可用额度比']>1) & (data['可用额度比']<=10)]['可用额度比'].count() 
b=data.loc[(data['可用额度比']>1) & (data['可用额度比']<=70000)]['可用额度比'].count()
a/b

计算发现可用额度比大于1小于等于10的变量约占所有大于1数据的93%,过大的值可能是不具有代表性的异常值。

(5) 负债率

从业务逻辑出发,负债率应该处于0-1之间,但根据散点图发现也有许多大于1的数字,异常值较多。由于这些异常值可能会影响分析,接下来分为<=1 和 >1 两个部分画图分析。

plt.figure(figsize=(8,5))
data_test_0=data.loc[data['负债率']<=1][data.好坏客户 == 0]['负债率']
data_test_1=data.loc[data['负债率']<=1][data.好坏客户 == 1]['负债率']
sns.distplot(data_test_0.dropna(),color='g')
sns.distplot(data_test_1.dropna(),color='r')
plt.legend(['0','1'])
plt.show()
sns.boxplot(y=data['负债率'])
image.png
image.png

当负债率小于等于1的时候,好客户更多集中于0-0.4之间,而坏客户分布较为平均且拥有长尾属性,负债率大于0.45后,违约风险较大。

plt.figure(figsize=(8,5))
data_test_0=data.loc[data['负债率']>1][data.好坏客户 == 0]['负债率']
data_test_1=data.loc[data['负债率']>1][data.好坏客户 == 1]['负债率']
sns.distplot(data_test_0.dropna(),color='g')
sns.distplot(data_test_1.dropna(),color='r')
plt.legend(['0','1'])
image.png

当负债率大于1的时候,出现坏客户的概率远大于好客户。另外,负债率存在极大值。

bins=[0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0]
data['负债率分箱']=pd.cut(data['负债率'],bins,right=True)
#违约率
debt_bad=data.groupby(['负债率分箱'])['好坏客户'].sum()
debt_total=data.groupby(['负债率分箱'])['好坏客户'].count()
debt_ratio=debt_bad/debt_total
debt_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

上图可看出负债率<=1时,负债率与违约率正相关趋势。负债率越高违约率越大。

bins=[1,5000,10000,20000,40000]
data['负债率分箱']=pd.cut(data['负债率'],bins,right=True)
#违约率
debt_bad=data.groupby(['负债率分箱'])['好坏客户'].sum()
debt_total=data.groupby(['负债率分箱'])['好坏客户'].count()
debt_ratio=debt_bad/debt_total
debt_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

上图可看出负债率>1时,基本符合负债率和违约率正相关的分析。

(6) 逾期笔数(逾期30-59天笔数、逾期60-89天笔数、逾期90天笔数)
#逾期笔数
list=['逾期30-59天笔数','逾期60-89天笔数','逾期90天笔数']
#a=[ax1,ax2,ax3]
b=0
fig,a=plt.subplots(2,3,figsize=(15,8))
for i in list:
    pvt=pd.pivot_table(data[['好坏客户',i]], index=i, columns='好坏客户', aggfunc=len)
    pvt.plot(kind='bar',ax=a[0,b])
    sns.boxplot(y=data[i],ax=a[1,b])
    b=b+1
image.png

由箱线图可以看出,三个逾期笔数均存在异常值,使用value_counts函数得知为96和98两个异常值,后续应剔除。

#将大于40的数据和40合并后看一下违约率的情况
Num_bad=data.groupby(['逾期30-59天笔数'])['好坏客户'].sum()
Num_total=data.groupby(['逾期30-59天笔数'])['好坏客户'].count()
Num_ratio=Num_bad/Num_total
Num_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png
Num_bad=data.groupby(['逾期60-89天笔数'])['好坏客户'].sum()
Num_total=data.groupby(['逾期60-89天笔数'])['好坏客户'].count()
Num_ratio=Num_bad/Num_total
Num_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png
Num_bad=data.groupby(['逾期90天笔数'])['好坏客户'].sum()
Num_total=data.groupby(['逾期90天笔数'])['好坏客户'].count()
Num_ratio=Num_bad/Num_total
Num_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

部分区间逾期笔数与违约率有正相关关系。

(7) 信贷数量、固定资产贷款数量
#资贷数量
list=['信贷数量','固定资产贷款数量']
b=0
fig,a=plt.subplots(2,2,figsize=(15,8))
for i in list:
    pvt=pd.pivot_table(data[['好坏客户',i]], index=i, columns='好坏客户', aggfunc=len)
    pvt.plot(kind='bar',ax=a[0,b])
    sns.boxplot(y=data[i],ax=a[1,b])
    b=b+1
image.png

将信贷数量大于40的数据和40合并后看一下违约率的情况。

data.loc[data['信贷数量']>40,'信贷数量']=40
Num_bad=data.groupby(['信贷数量'])['好坏客户'].sum()
Num_total=data.groupby(['信贷数量'])['好坏客户'].count()
Num_ratio=Num_bad/Num_total
Num_ratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

信贷数量与违约率无明显相关性。下面将固定资产贷款数量大于8的数据和8合并后看一下违约率的情况。

data.loc[data['固定资产贷款数量']>8,'固定资产贷款数量']=8
Num_bad=data.groupby(['固定资产贷款数量'])['好坏客户'].sum()
Num_total=data.groupby(['固定资产贷款数量'])['好坏客户'].count()
Num_ratio=Num_bad/Num_total
Num_ratio.plot(kind='bar',figsize=(8,6),color='#4682B4')
image.png

由图可知,固定资产贷款数量从1开始,违约率随着固定资产贷款数量增大而增大。

(8) 家属数量
plt.figure(figsize=(8,5))
cus_class=data['家属数量'].value_counts()
cus_class.plot('bar',color='#4682B4')
plt.title("家属数量分布直方图")
plt.xlabel("家属数量")
plt.ylabel("频数")
for x,y in zip(cus_class.index,cus_class):
    plt.text(x, y, '%.0f' % y, ha='center', va= 'bottom',fontsize=10)
plt.show()
plt.figure(figsize=(8,5))
sns.boxplot(y=data['家属数量'])
image.png
image.png

将大于8的数据和8合并后看一下家属数量的情况。

Numestate_dlq=data.groupby(['家属数量'])['好坏客户'].sum()
Numestate_total=data.groupby(['家属数量'])['好坏客户'].count()
Numestate_dlqratio=Numestate_dlq/Numestate_total
Numestate_dlqratio.plot(kind='bar',figsize=(8,5),color='#4682B4')
image.png

上图所示,家属数量为6时违约率最高,超过14%。但家属数量与违约率并无显著相关性。

三、数据预处理

1. 重复值处理

#去除训练集中的完全重复行
data.drop_duplicates(keep='first',inplace=True)
print("预处理后训练集大小 : {} ".format(data.shape))

由于训练集数据量较大,难免存在重复数据,为保证数据质量,需要将训练集中完全重复的行数据去除。

image.png

通过上述预处理后,训练集长度变为149391条,列数变为11列。

2. 缺失值处理

image.png

在前文描述性统计部分中,我们发现月收入缺失约19.6%,家属数量缺失约2.6%。

data['家属数量'].fillna(data['家属数量'].median(),inplace=True) # 填充中位数

由于“家属数量”缺失比例较少,这里选择直接用中位数进行填充。而月收入变量缺失较多,且根据业务逻辑推测此变量对因变量影响较大,这里选用随机森林填补法来填充,即将缺失的特征值作为预测值,将未缺失的“月收入”数据作为训练样本的标签。

#随机森林预测缺失值
data_Forest=data.iloc[:,[5,0,1,2,3,4,6,7,8,9,10]]
MonthlyIncome_isnull=data_Forest.loc[data['月收入'].isnull(),:]
MonthlyIncome_notnull=data_Forest.loc[data['月收入'].notnull(),:]
from sklearn.ensemble import RandomForestRegressor
X=MonthlyIncome_notnull.iloc[:,1:].values
y=MonthlyIncome_notnull.iloc[:,0].values
regr=RandomForestRegressor(random_state=0,n_estimators=200,n_jobs=-1)
regr.fit(X,y)
MonthlyIncome_fillvalue=regr.predict(MonthlyIncome_isnull.iloc[:,1:].values).round(0)
#填充缺失值
data.loc[data['月收入'].isnull(),'月收入']=MonthlyIncome_fillvalue

3. 异常值处理

由value_counts函数可知,年龄变量存在等于0的异常值,这里进行删除。另外,通过箱线图,发现逾期30-59天笔数、逾期60-80天笔数、逾期90天笔数,这三个变量均存在严重的离群值,应进行异常值处理。

data=data[data['年龄']>0]
data=data[data['逾期30-59天笔数']<80]
data=data[data['逾期60-89天笔数']<80]
data=data[data['逾期90天笔数']<80]

4. 变量相关性分析

由于变量间共线性问题在部分机器学习模型中会显著影响模型性能,需针对选择的模型进行特殊处理,故这里进行相关性分析检验。

fig = plt.figure(figsize = (8,5))
sns.heatmap(data.iloc[:,:11].corr(),annot=True, cmap='YlGnBu', annot_kws={'size': 9})
image.png

上图为各自变量的相关系数矩阵,颜色越深表明两变量相关性越高。由图可知,除对角线区域,大多数区域颜色较浅,未有变量相关性超过0.5,基本不存在变量间共线性问题。

四、分箱与变量选择

1. 特征变量标准化

本文计划使用逻辑回归模型,其中数值型变量数据需要进行归一化处理使数值在同一个数量级上,保证逻辑回归的收敛速度。但由于后文会使用WOE转换,故这里不另外进行标准化处理。

2. 特征离散化(分箱)

由于本文选择用逻辑回归建立评分卡模型,连续变量需要进行离散化,该处理会使模型更稳定,降低过拟合的风险。首先将连续变量进行离散化分箱处理,通过比较指标分箱后的WOE与VI值进一步确定指标对因变量的贡献度,选取贡献高的变量引入后续模型。

常用的分箱方法包括无监督分箱(等距分箱、等频分箱和聚类分箱)和有监督分箱(卡方分箱和best-ks分箱)。下文将采用卡方分箱法。卡方分箱是依赖于卡方检验的分箱方法,在统计指标上选择卡方统计量(chi-Square)进行判别,分箱的基本思想是判断相邻的两个区间是否有分布差异,基于卡方统计量的结果进行自下而上的合并,直到满足分箱的限制条件为止。

temp = data[['年龄','好坏客户']]
# 定义一个卡方分箱(可设置参数置信度水平与箱的个数)停止条件为大于置信水平且小于bin的数目
def ChiMerge(df, variable, flag, confidenceVal=3.841, bin=10, sample = None):  
    '''
    运行前需要 import pandas as pd 和 import numpy as np
    df:传入一个数据框仅包含一个需要卡方分箱的变量与正负样本标识(正样本为1,负样本为0)
    variable:需要卡方分箱的变量名称(字符串)
    flag:正负样本标识的名称(字符串)
    confidenceVal:置信度水平(默认是不进行抽样95%)
    bin:最多箱的数目
    sample: 为抽样的数目(默认是不进行抽样),因为如果观测值过多运行会较慢
    '''
#进行是否抽样操作
    if sample != None:
        df = df.sample(n=sample)
    else:
        df   

#进行数据格式化录入
    total_num = df.groupby([variable])[flag].count()  # 统计需分箱变量每个值数目
    total_num = pd.DataFrame({'total_num': total_num})  # 创建一个数据框保存之前的结果
    positive_class = df.groupby([variable])[flag].sum()  # 统计需分箱变量每个值正样本数
    positive_class = pd.DataFrame({'positive_class': positive_class})  # 创建一个数据框保存之前的结果
    regroup = pd.merge(total_num, positive_class, left_index=True, right_index=True,
                       how='inner')  # 组合total_num与positive_class
    regroup.reset_index(inplace=True)
    regroup['negative_class'] = regroup['total_num'] - regroup['positive_class']  # 统计需分箱变量每个值负样本数
    regroup = regroup.drop('total_num', axis=1)
    np_regroup = np.array(regroup)  # 把数据框转化为numpy(提高运行效率),每行为年龄,坏客户数,好客户数
    print('已完成数据读入,正在计算数据初处理')

#处理连续没有正样本或负样本的区间,并进行区间的合并(以免卡方值计算报错)
    i = 0
    while (i <= np_regroup.shape[0] - 2):  #np_regroup.shape[0]行数
        if ((np_regroup[i, 1] == 0 and np_regroup[i + 1, 1] == 0) or ( np_regroup[i, 2] == 0 and np_regroup[i + 1, 2] == 0)):
            np_regroup[i, 1] = np_regroup[i, 1] + np_regroup[i + 1, 1]  # 正样本
            np_regroup[i, 2] = np_regroup[i, 2] + np_regroup[i + 1, 2]  # 负样本
            np_regroup[i, 0] = np_regroup[i + 1, 0]
            np_regroup = np.delete(np_regroup, i + 1, 0)
            i = i - 1
        i = i + 1
 
#对相邻两个区间进行卡方值计算
    chi_table = np.array([])  # 创建一个数组保存相邻两个区间的卡方值
    for i in np.arange(np_regroup.shape[0] - 1):
        chi = (np_regroup[i, 1] * np_regroup[i + 1, 2] - np_regroup[i, 2] * np_regroup[i + 1, 1]) ** 2 \
          * (np_regroup[i, 1] + np_regroup[i, 2] + np_regroup[i + 1, 1] + np_regroup[i + 1, 2]) / \
          ((np_regroup[i, 1] + np_regroup[i, 2]) * (np_regroup[i + 1, 1] + np_regroup[i + 1, 2]) * (
          np_regroup[i, 1] + np_regroup[i + 1, 1]) * (np_regroup[i, 2] + np_regroup[i + 1, 2]))
        chi_table = np.append(chi_table, chi)
    print('已完成数据初处理,正在进行卡方分箱核心操作')

#把卡方值最小的两个区间进行合并(卡方分箱核心)
    while (1):
        if (len(chi_table) <= (bin - 1) and min(chi_table) >= confidenceVal):
            break
        chi_min_index = np.argwhere(chi_table == min(chi_table))[0]  # 找出卡方值最小的位置索引
        np_regroup[chi_min_index, 1] = np_regroup[chi_min_index, 1] + np_regroup[chi_min_index + 1, 1]
        np_regroup[chi_min_index, 2] = np_regroup[chi_min_index, 2] + np_regroup[chi_min_index + 1, 2]
        np_regroup[chi_min_index, 0] = np_regroup[chi_min_index + 1, 0]
        np_regroup = np.delete(np_regroup, chi_min_index + 1, 0)

        if (chi_min_index == np_regroup.shape[0] - 1):  # 最小值试最后两个区间的时候
            # 计算合并后当前区间与前一个区间的卡方值并替换
            chi_table[chi_min_index - 1] = (np_regroup[chi_min_index - 1, 1] * np_regroup[chi_min_index, 2] - np_regroup[chi_min_index - 1, 2] * np_regroup[chi_min_index, 1]) ** 2 \
                                           * (np_regroup[chi_min_index - 1, 1] + np_regroup[chi_min_index - 1, 2] + np_regroup[chi_min_index, 1] + np_regroup[chi_min_index, 2]) / \
                                       ((np_regroup[chi_min_index - 1, 1] + np_regroup[chi_min_index - 1, 2]) * (np_regroup[chi_min_index, 1] + np_regroup[chi_min_index, 2]) * (np_regroup[chi_min_index - 1, 1] + np_regroup[chi_min_index, 1]) * (np_regroup[chi_min_index - 1, 2] + np_regroup[chi_min_index, 2]))
            # 删除替换前的卡方值
            chi_table = np.delete(chi_table, chi_min_index, axis=0)

        else:
            # 计算合并后当前区间与前一个区间的卡方值并替换
            chi_table[chi_min_index - 1] = (np_regroup[chi_min_index - 1, 1] * np_regroup[chi_min_index, 2] - np_regroup[chi_min_index - 1, 2] * np_regroup[chi_min_index, 1]) ** 2 \
                                       * (np_regroup[chi_min_index - 1, 1] + np_regroup[chi_min_index - 1, 2] + np_regroup[chi_min_index, 1] + np_regroup[chi_min_index, 2]) / \
                                       ((np_regroup[chi_min_index - 1, 1] + np_regroup[chi_min_index - 1, 2]) * (np_regroup[chi_min_index, 1] + np_regroup[chi_min_index, 2]) * (np_regroup[chi_min_index - 1, 1] + np_regroup[chi_min_index, 1]) * (np_regroup[chi_min_index - 1, 2] + np_regroup[chi_min_index, 2]))
            # 计算合并后当前区间与后一个区间的卡方值并替换
            chi_table[chi_min_index] = (np_regroup[chi_min_index, 1] * np_regroup[chi_min_index + 1, 2] - np_regroup[chi_min_index, 2] * np_regroup[chi_min_index + 1, 1]) ** 2 \
                                       * (np_regroup[chi_min_index, 1] + np_regroup[chi_min_index, 2] + np_regroup[chi_min_index + 1, 1] + np_regroup[chi_min_index + 1, 2]) / \
                                   ((np_regroup[chi_min_index, 1] + np_regroup[chi_min_index, 2]) * (np_regroup[chi_min_index + 1, 1] + np_regroup[chi_min_index + 1, 2]) * (np_regroup[chi_min_index, 1] + np_regroup[chi_min_index + 1, 1]) * (np_regroup[chi_min_index, 2] + np_regroup[chi_min_index + 1, 2]))
            # 删除替换前的卡方值
            chi_table = np.delete(chi_table, chi_min_index + 1, axis=0)
    print('已完成卡方分箱核心操作,正在保存结果')

#把结果保存成一个数据框
    result_data = pd.DataFrame()  # 创建一个保存结果的数据框
    result_data['variable'] = [variable] * np_regroup.shape[0]  # 结果表第一列:变量名
    list_temp = []
    for i in np.arange(np_regroup.shape[0]):
        if i == 0:
            x = '0' + ',' + str(np_regroup[i, 0])
        elif i == np_regroup.shape[0] - 1:
            x = str(np_regroup[i - 1, 0]) + '+'
        else:
            x = str(np_regroup[i - 1, 0]) + ',' + str(np_regroup[i, 0])
        list_temp.append(x)
    result_data['interval'] = list_temp  # 结果表第二列:区间
    result_data['good_count'] = np_regroup[:, 2]  # 结果表第三列:负样本数目
    result_data['bad_count'] = np_regroup[:, 1]  # 结果表第四列:正样本数目
    bad_total=result_data['bad_count'].sum()
    good_total=result_data['good_count'].sum()
    result_data['woe']=np.log((result_data['bad_count']/bad_total)/(result_data['good_count']/good_total))  # 结果表第五列:WOE值
    result_data['VI']=((result_data['bad_count']/bad_total)-(result_data['good_count']/good_total))*result_data['woe']
    return result_data

#调用参数并保存结果到集合
bins = ChiMerge(temp, '年龄','好坏客户', confidenceVal=3.841, bin=10,sample=None)
print(bins)
vi=bins['VI'].sum()
print('%s特征的VI值为%.4f' %(temp.columns[0],vi))
vi_total={}
vi_total[temp.columns[0]] = vi

以年龄特征为例,分箱结果如下:

image.png

接着对其余连续性变量同样进行卡方分箱,由于特征较多而处理方式一致,这里省略其他特征的具体分箱结果。

3. WOE和IV值计算与分析

由于逻辑回归模型需要数值作为变量输入,接下来需要计算分箱对应WOE值。WOE越大,代表这个变量对“坏客户“标签的贡献度越大。

IV(Information Value)是与WOE密切相关的一个指标,常用来评估变量的预测能力,可以结合其结果筛选变量。在应用实践中,其评价标准如下:

image.png

这里依旧以年龄变量为例,年龄特征的VI值为0.2589。接着对其余连续性变量同样进行卡方分箱,并得到对应WOE和IV值。由于特征较多而处理方式一致,这里省略其他特征的具体结果。

4. 特征变量选择

最终我们将VI值进行整理,得到各特征VI值条形图。

def draw_from_dict(dicdata,RANGE):
    by_value = sorted(dicdata.items(),key = lambda item:item[1],reverse=True)
    print(by_value)
    x = []
    y = []
    for d in by_value:
        x.append(d[0])
        y.append(d[1])
    plt.yticks(np.arange(len(x)),x)
    plt.barh(np.arange(len(x)), y[0:RANGE])
    plt.show()
    return 
a=draw_from_dict(vi_total,len(vi_total))
image.png

根据VI预测效果表,选取VI>0.1的变量进入逻辑回归模型,包括可用额度比、逾期60-89天笔数、逾期30-59天笔数、逾期90天笔数和年龄。

五、 模型建立与评价

1. WOE特征变换

逻辑回归模型基于广义线性回归模型,求参需要用到梯度下降法,为了加快迭代速度,不同特征的变化范围规模相差不宜过大,如果用数值直接带入逻辑回归模型,必须进行变量缩放。本文对变量进行WOE处理会将数值变量进行分箱,可以达到相似效果。将分箱后求得的WOE值替换对应数值,方便后续建立逻辑回归与评分模型。

image.png

image.png

2. 样本均衡处理

image.png

原训练集正样本占比93.38%,负样本6.62%,样本严重不平衡。这里使用SMOTE方法进行过采样处理,处理后样本共278582条,正负各占50%。

3. 模型构建

本文使用逻辑回归模型

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
train_x,test_x,train_y,test_y = train_test_split(x,y,test_size = 0.3,random_state = 0)
train = pd.concat([train_y,train_x], axis =1)
test = pd.concat([test_y,test_x], axis =1)
train = train.reset_index(drop=True)
test = test.reset_index(drop=True)
lr = LogisticRegression(penalty= 'l1',solver='liblinear')
lr.fit(train_x,train_y)

4. 模型评估(ROC、AUC与KS评估)

AUC是一个衡量评分卡区分能力的量化指标,AUC面积越大,表示模型区分能力越强,在衡量一个模型是否有效的时候,AUC至少需要大于0.5。KS值是一个衡量好坏客户分数距离的上限值,具体做法为将对于各个分数区间对应的好坏客户累计占比进行相减,取最大值。好坏客户之间的距离越大,k-s指标越高,模型的区分能力越强。

#绘制roc曲线
from sklearn.metrics import roc_curve, auc
y_pred= lr.predict(train_x)  
train_predprob = lr.predict_proba(train_x)[:,1]  
test_predprob = lr.predict_proba(test_x)[:,1] 
FPR,TPR,threshold =roc_curve(test_y,test_predprob)
ROC_AUC= auc(FPR,TPR)
plt.plot(FPR, TPR, 'b', label='AUC = %0.2f' % ROC_AUC)
plt.legend(loc='lower right')
plt.plot([0, 1], [0, 1], 'r--')
plt.xlim([0, 1])
plt.ylim([0, 1])
plt.ylabel('TPR')
plt.xlabel('FPR')
plt.show()
ks=max(TPR-FPR).round(2)
print("auc:{}       ks:{}".format(ROC_AUC.round(2),ks))
image.png

AUC值约为0.84,说明该模型的拟合效果较好。KS值约为0.52,一般KS>0.3即可认为模型有比较好的预测准确性。总体来说模型效果不错。

六、测试集预测与信用评分卡建立

1. 导入测试集及数据预处理

f=open('F:\GiveMeSomeCredit\cs-test.csv')
data2=pd.read_csv(f)
column={'Unnamed: 0':'用户ID',
        'SeriousDlqin2yrs':'好坏客户',
        'RevolvingUtilizationOfUnsecuredLines':'可用额度比',
        'age':'年龄',
        'NumberOfTime30-59DaysPastDueNotWorse':'逾期30-59天笔数',
        'DebtRatio':'负债率',
        'MonthlyIncome':'月收入',
        'NumberOfOpenCreditLinesAndLoans':'信贷数量',
        'NumberOfTimes90DaysLate':'逾期90天笔数',
        'NumberRealEstateLoansOrLines':'固定资产贷款数量',
        'NumberOfTime60-89DaysPastDueNotWorse':'逾期60-89天笔数',
        'NumberOfDependents':'家属数量'}
data2.rename(columns=column,inplace=True)
#去除训练集中用户ID列
data2.drop(['用户ID'],axis=1,inplace=True)

将测试集导入,并进行与前文训练集一样的数据预处理,包括缺失值填补,异常值删除,重复值删除等。

2. 分箱及WOE转换

image.png
image.png

3. 好坏客户预测

基于前文建立的模型给出测试集预测结果。

final_test_predict=data_test.reset_index().drop('index',axis=1).copy()
final_test_predict=final_test_predict.iloc[:,[1,2,3,4,5]]
y_pred= lr.predict(final_test_predict)
final_test_predict['好坏客户']=pd.DataFrame(y_pred)
final_test_predict
image.png

4. 评分卡建立

在建立标准评分卡之前,需要选取几个评分卡参数:基础分值、 PDO(Point-to-Double Odds,好坏比每升高一倍,分数升高PDO个单位)和好坏比。 这里取600分为基础分值,PDO为20 (每高20分好坏比翻一倍),好坏比取20。

# 个人总分=基础分+各部分得分
import math
B = 20 / math.log(2)
A = 600 - B / math.log(20)
# 基础分
base = round(A+B *lr.intercept_[0], 0)
base

下面计算各变量部分的分数。各部分得分函数:

#计算分数函数
def compute_score(coe,woe,factor):
    scores=[]
    for w in woe:
        score=round(-coe*w*factor,0)
        scores.append(score)
    return scores

x1_revol = compute_score(lr.coef_[0][0], woe_revol, B)
x2_age = compute_score(lr.coef_[0][1], woe_age, B)
x3_30 = compute_score(lr.coef_[0][2], woe_30, B)
x4_90 = compute_score(lr.coef_[0][3], woe_60, B)
x5_60 = compute_score(lr.coef_[0][4], woe_90, B)

得到每个变量不同区间的对应分数。

image.png

得到训练集每个用户的最终信用评分。

#根据变量计算分数
def change_score(series,cut,score):
    list = []
    i = 0
    while i < len(series):
        value = series[i]
        j = len(cut) - 2
        m = len(cut) - 2
        while j >= 0:
            if value >= cut[j]:
                j = -1
            else:
                j -= 1
                m -= 1
        list.append(score[m])
        i += 1
    return list
test1=data2.reset_index().drop('index',axis=1).copy()
test1=test1.iloc[:,[1,2,3,9,7]]
#计算test里面的分数
test1['x1_可用额度比'] = pd.Series(change_score(test1['可用额度比'], cutx1, x1_revol))
test1['x2_年龄'] = pd.Series(change_score(test1['年龄'], cutx2, x2_age))
test1['x3_逾期30-59天笔数'] = pd.Series(change_score(test1['逾期30-59天笔数'], cutx3, x3_30))
test1['x4_逾期60-89天笔数'] = pd.Series(change_score(test1['逾期60-89天笔数'], cutx4, x4_60))
test1['x5_逾期90天笔数'] = pd.Series(change_score(test1['逾期90天笔数'], cutx5, x5_90))
test1['Score'] =test1['x1_可用额度比'] + test1['x2_年龄'] + test1['x3_逾期30-59天笔数'] + test1['x4_逾期60-89天笔数'] + test1['x5_逾期90天笔数'] + base
test1.to_csv('F:\ScoreData.csv', index=False)
image.png

这里得分越低的用户,违约风险越大。评分卡建立后,应通过设定cut-off准入分数将客群划分为不同等级。这里的准入分数可以通过通过率和坏账率评估模型在业务中的表现来设定,也需要结合政策、误差等因素人工调整。下图为测试集评分卡分数分布情况。

评分卡分数分布
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容