分箱的方法非常重点!!!!!
而对于个人来说,有”四张卡“来评判个人的信用程度:A卡,B卡,C卡和F卡。而众人常说的“评分卡”其实是指A卡,又称为申请者评级模型,主要应用于相关融资类业务中新用户的主体评级,即判断金融机构是否应该借钱给一个新用户,如果这个人的风险太高,可以拒绝贷款。
一个完整的模型开发,需要有以下流程:
本案例以个人消费类贷款数据,来简单介绍A卡的建模和制作流程,本次核心会在”数据清洗“和“模型开发”上。模型检验与评估也非常重要,但是在本案例中,内容已经太多,就不再去赘述了。
其实日常在导库的时候,并不是一次性能够知道我们要用的所有库的。通常都是在建模过程中逐渐导入需要的库。
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression as LR 逻辑回归
data = pd.read_csv('rankingcard.csv',index_col=0)
在这一步我们要了解样本总体的大概情况,比如查看缺失值,量纲是否统一,是否需要做哑变量等等。
data.head()
data.info()
data.shape
>(150000, 11)
现实数据,尤其是银行业数据,可能会存在的一个问题就是样本重复,即有超过一行的样本所显示的所有特征都一样。有时候可能时人为输入重复,有时候可能是系统录入重复,总而言之我们必须对数据进行去重处理。可能有人会说,难道不可能出现说两个样本的特征就是一模一样,但他们是两个样本吗?比如,两个人,一模一样的名字,年龄,性别,学历,工资……当特征量很少的时候,这的确是有可能的,但一些指标,比如说家属人数,月收入,已借有的房地产贷款数量等等,几乎不可能都出现一样。尤其是银行业数据经常是几百个特征,所有特征都一样的可能性是微乎其微的。即便真的出现了如此极端的情况,我们也可以当作是少量信息损失,将这条记录当作重复值除去。
#去除重复值
data.drop_duplicates(inplace=True)
data.info()
#恢复索引
data.index = range(1,data.shape[0]+1)
data.info()
#查看有多少缺失值
data.isnull().sum()
#起到的效果相同,看缺失值占总数据的比例
data.isnull().mean()
#data.isnull().sum()/data.shape[0]
第二个要面临的问题,就是缺失值。在这里我们需要填补的特征是“收入”和“家属人数”。“家属人数”缺失很少,仅缺失了大约2.5%,可以考虑直接删除,或者使用均值来填补。“收入”缺失了几乎20%,并且我们知道,“收入”必然是一个对信用评分来说很重要的因素,因此这个特征必须要进行填补。在这里,我们使用均值填补“家属人数”。
data.loc[:,'NumberOfDependents'].fillna(int(data.loc[:,'NumberOfDependents'].mean()),inplace=True)
注意:如果选择的是删除那些缺失了2.5%的特征,千万不要忘记恢复索引
对于收入这个特征来说,缺失了将近20%,对整体的影响还是很大的,在这里,使用随机森林来对缺失值进行填补‘收入’,关于随机森林填补缺失值,详细请看随即森丽填补缺失值
我们来写一个能够填补任何列的函数:
def fill_missing_rf(x,y,to_fill):
"""
使用随机森林填补一个特征的缺失值的函数
参数:
X:要填补的特征矩阵
y:完整的,没有缺失值的标签
to_fill:字符串,要填补的那一列的名称
"""
#构建新的特征矩阵和标签
df = x.copy()
fill = df.loc[:,to_fill]
#原本的y是一维的,但是特征矩阵是二维的,所以需要将y变成DataFrame的形式在进行拼接
df = pd.concat([df.loc[:,df.columns!=to_fill],pd.DataFrame(y)],axis=1)
#找出训练集和测试集
ytrain = fill.loc[fill.notnull()]
ytest = fill.loc[fill.isnull()]
xtrain = df.loc[ytrain.index,:]
xtest = df.loc[ytest.index,:]
#使用回归森林填补缺失值
from sklearn.ensemble import RandomForestRegressor as RFR
rfr = RFR(n_estimators=100)
rfr.fit(xtrain,ytrain)
ypredict = rfr.predict(xtest)
return ypredict
接下来,我们来创造函数需要的参数,将参数导入函数,产出结果:
#分离特征和标签
x = data.iloc[:,1:]
y = data.iloc[:,0]
x.shape
>(149391, 10)
y_pred = fill_missing_rf(x,y,'MonthlyIncome')
#将预测出来的值覆盖到原数据中
data.loc[data.loc[:,'MonthlyIncome'].isnull(),'MonthlyIncome'] = y_pred
现实数据永远都会有一些异常值,首先我们要去把他们捕捉出来,然后观察他们的性质。注意,我们并不是要排除掉所有异常值,相反很多时候,异常值是我们的重点研究对象,比如说,双十一中购买量超高的品牌,或课堂上让很多学生都兴奋的课题,这些是我们要重点研究观察的。
日常处理异常值,我们使用箱线图或者 3 σ 3\sigma 3σ法则来找到异常值
但在银行数据中,我们希望排除的“异常值”不是一些超高或超低的数字,而是一些不符合常理的数据:比如,收入不能为负数,但是一个超高水平的收入却是合理的,可以存在的。所以在银行业中,我们往往就使用普通的描述性统计来观察数据的异常与否与数据的分布情况。
注意,这种方法只能在特征量有限的情况下进行,如果有几百个特征又无法成功降维或特征选择不管用,那还是用 3 σ 3\sigma 3σ法则 比较好。
#描述性统计
data.describe([0.01,0.1,0.25,.5,.75,.9,.99]).T
异常值也被我们观察到,年龄的最小值居然有0,这不符合银行的业务需求,即便是儿童账户也要至少8岁,我们可以查看一下年龄为0的人有多少
(data.loc[:,'age']==0).sum()
>1
#发现只有一个人年龄为0,可以判断这肯定是录入失误造成的,可以当成是缺失值来处理,直接删除掉这个样本
data = data.loc[data.loc[:,'age']!=0,:]
data.describe([0.01,0.1,0.25,.5,.75,.9,.99]).T
"""
另外,有三个指标看起来很奇怪:
"NumberOfTime30-59DaysPastDueNotWorse"
"NumberOfTime60-89DaysPastDueNotWorse"
"NumberOfTimes90DaysLate"
这三个指标分别是“过去两年内出现35-59天逾期但是没有发展的更坏的次数”,“过去两年内出现60-89天逾期但是没
有发展的更坏的次数”,“过去两年内出现90天逾期的次数”。这三个指标,在99%的分布的时候依然是2,最大值却是
98,看起来非常奇怪。一个人在过去两年内逾期35~59天98次,一年6个60天,两年内逾期98次这是怎么算出来的?
我们可以去咨询业务人员,请教他们这个逾期次数是如何计算的。如果这个指标是正常的,那这些两年内逾期了98次的
客户,应该都是坏客户。在我们无法询问他们情况下,我们查看一下有多少个样本存在这种异常:
"""
data[data.loc[:,"NumberOfTimes90DaysLate"] > 90]
#有225个样本存在这样的情况,并且这些样本,我们观察一下,标签并不都是1,他们并不都是坏客户。因此,我们基
#本可以判断,这些样本是某种异常,应该把它们删除。
data = data.loc[data.loc[:,'NumberOfTimes90DaysLate']<90,:]
#恢复索引
data.index = range(1,data.shape[0]+1)
data.info()
#在查看一遍
data.describe([0.01,0.1,0.25,.5,.75,.9,.99]).T
在描述性统计结果中,我们可以观察到数据量纲明显不统一,而且存在一部分极偏的分布
虽然逻辑回归对于数据没有分布要求,但是我们知道如果数据服从正态分布的话梯度下降可以收敛得更快。但在这里,我们不对数据进行标准化处理,也不进行量纲统一。
无论算法有什么样的规定,无论统计学中有什么样的要求,我们的最终目的都是要为业务服务。现在我们要制作评分卡,评分卡是要给业务人员们使用的基于新客户填写的各种信息为客户打分的一张卡片,而为了制作这张卡片,我们需要对我们的数据进行一个“分档”,比如说,年龄20-30岁为一档,年龄30-50岁为一档,月收入1W以上为一档,5000~1W为一档,每档的分数不同。
一旦我们将数据统一量纲,或者标准化了之后,数据大小和范围都会改变,统计结果是漂亮了,但是对于业务人员来说,他们完全无法理解,标准化后的年龄在0.00328~0.00467之间为一档是什么含义,并且,新客户填写的信息,天生就是量纲不统一的。
由于业务要求,在制作评分卡的时候,我们要尽量保持数据的原貌,年龄就是8~110的数字,收入就是
大于0,最大值可以无限的数字,即便量纲不统一,我们也不对数据进行标准化处理。
x = data.iloc[:,1:]
y = data.iloc[:,0]
#查看y值的分布
y.value_counts()
x.shape
>(149165, 10)
n_sample = x.shape[0]
n_1_sample = y.value_counts()[1]
n_0_sample = y.value_counts()[0]
print('样本个数:{}; 1占{:.2%}; 0占{:.2%}'.format(n_sample,n_1_sample/n_sample,n_0_sample/n_sample))
可以看出,样本严重不均衡。虽然大家都在努力防范信用风险,但实际违约的人并不多。并且,银行并不会真的一棒子打死所有会违约的人,很多人是会还钱的,只是忘记了还款日,很多人是不愿意欠人钱的,但是当时真的很困难,资金周转不过来,所以发生逾期,但一旦他有了钱,他就会把钱还上。对于银行来说,只要你最后能够把钱还上,我都愿意借钱给你,因为我借给你就有收入(利息)。
对于银行来说,真正想要被判别出来的其实是”恶意违约“的人,而这部分人数非常非常少,样本就会不均衡。这一直是银行业建模的一个痛点:我们永远希望捕捉少数类。
逻辑回归分类器第五节提到过,逻辑回归中使用最多的是上采样方法来平衡样本(就是将不平衡的样本标签,类似于复制的概念,将特征及 标签多多复制,直到不平衡的标签个数同原来最多的标签数相同,及达到平衡)
#在prompt安装:pip install imblearn
#imblearn是专门用来处理不平衡数据集的库,在处理样本不均衡问题中性能高过sklearn很多
#imblearn里面也是一个个的类,也需要进行实例化,fit拟合,和sklearn用法相似
import imblearn#判断安装是否成功
from imblearn.over_sampling import SMOTE
sm = SMOTE(random_state=500)
x,y = sm.fit_sample(x,y)
#fit_sample会报错,使用fit_resample
x,y = sm.fit_resample(x,y)
x.shape
>(278584, 10)
#发现样本变成了278584行
y.value_counts()
n_1_sample = y.value_counts()[1]
n_2_sample = y.value_counts()[0]
print('样本个数:{}; 1占{:.2%}; 0占{:.2%}'.format(n_sample_,n_1_sample/n_sample_,n_0_sample/n_sample_))
from sklearn.model_selection import train_test_split
#y = pd.DataFrame(y)如果在这里不写,那么划分完的y就是series,如果写了就是dataframe类型
x_train, x_vail, y_train, y_vail = train_test_split(x,y,test_size=0.3,random_state=500)
#制作训练集
model_data = pd.concat([y_train,x_train],axis=1)
model_data.head()
#序号重新编排
model_data.index = range(1,model_data.shape[0]+1)
model_data.head()
#制作测试集
vail_data = pd.concat([y_vail,x_vail],axis=1)
vail_data.index = range(1,vail_data.shape[0]+1)
vail_data.head()
#保存为csv文件
model_data.to_csv('model_data.csv')
vail_data.to_csv('vail_data.csv')
前面提到过,我们要制作评分卡,是要给各个特征进行分档,以便业务人员能够根据新客户填写的信息为客户打分。因此在评分卡制作过程中,一个重要的步骤就是分箱。可以说,分箱是评分卡最难,也是最核心的思路,分箱的本质,其实就是离散化连续变量,好让拥有不同属性的人被分成不同的类别(打上不同的分数),其实本质比较类似于聚类。
要分多少个箱子?
离散化连续变量必然伴随着信息的损失,并且箱子越少,信息损失越大。为了衡量特征上的信息量以及特征对预测函数的贡献,银行业定义了概念Information value(IV):
I V = ∑ i = 1 N ( g o o d % − b a d % ) ∗ W O E i IV=\sum_{i=1}^{N}(good\%-bad\%)*WOE_i IV=i=1∑N(good%−bad%)∗WOEi
N : 这个特征上箱子的个数
i : 代表每个箱子
g o o d % good\% good% : 这个箱内的优质客户(标签为0的客户)占整个特征中所有优质客户的比例
b a d % bad\% bad% : 这个箱子里的坏客户(就是那些会违约,标签为1的那些客户)占整个特征中所有坏客户的 比例
W O E i WOE_i WOEi :
W O E i = ln ( g o o d % b a d % ) WOE_i=\ln(\frac{good\%}{bad\%}) WOEi=ln(bad%good%)
WOE是对一个箱子来说的,WOE越大,代表了这个箱子里的优质客户越多。而IV是对整个特征来说的,IV代表的意义是我们特征上的信息量以及这个特征对模型的贡献,由下表来控制:
可见,IV并非越大越好,我们想要找到IV的大小和箱子个数的平衡点。箱子越多,IV必然越小,因为信息损失会非常多,所以,我们会对特征进行分箱,然后计算每个特征在每个箱子数目下的WOE值,利用IV值的曲线,找出合适的分箱个数。
分箱想要达成的效果
我们希望不同属性的人有不同的分数,因此我们希望在同一个箱子内的人的属性是尽量相似的,而不同箱子的人的属性是尽量不同的,即业界常说的”组间差异大,组内差异小“。
对于评分卡来说,就是说我们希望一个箱子内的人违约概率是类似的,而不同箱子的人的违约概率差距很大,即WOE差距要大,并且每个箱子中坏客户所占的比重( b a d % bad\% bad%)也要不同。那我们,可以使用卡方检验来对比两个箱子之间的相似性,如果两个箱子之间卡方检验的P值很小,则说明他们非常相似,那我们就可以将这两个箱子合并为一个箱子。
特征进行分箱的步骤
1)我们首先把连续型变量分成一组数量较多的分类型变量,比如,将几万个样本分成100组,或50组
2)确保每一组中都要包含两种类别的样本,否则IV值会无法计算
3)我们对相邻的组进行卡方检验,卡方检验的P值很小的组进行合并(p值越小,相关性越强),直到数据中的组数小于设定的N箱为止
4)我们让一个特征分别分成[2,3,4…20]箱,观察每个分箱个数下的IV值如何变化,找出最适合的分箱个数
5)分箱完毕后,我们计算每个箱的WOE值, b a d % bad\% bad% ,观察分箱效果
这些步骤都完成后,我们可以对各个特征都进行分箱,然后观察每个特征的IV值,以此来挑选特征
接下来,以"age"为例子,来看看分箱如何完成
#如果是新开的代码界面,就导入数据处理完之后的csv文件即可
model_data = pd.read_csv('model_data.csv',index_col=0)
#分箱函数qcut,返回分箱后所在的区间以及各个区间的上下限
#如果还没有qcut这个列就自动建立这个列,这一列其实就是每个样本所对应的箱子
model_data.loc[:,'qcut'], updown = pd.qcut(model_data.loc[:,'age'],retbins=True,q=20)
"""
pd.qcut,基于分位数的分箱函数,本质是将连续型变量离散化
只能够处理一维数据。返回箱子的上限和下限
参数q:要分箱的个数
参数retbins=True 获取分组边界值的列表,也就是上面的updown
现在返回两个值:每个样本属于哪个箱子,以及所有箱子的上限和下限
"""
#在这里时让model_data新添加一列叫做“分箱”,这一列其实就是每个样本所对应的箱子
model_data.head()
#所有箱子的上限和下限
updown
#看不同箱含有多少行数据
model_data.loc[:,'qcut'].value_counts()
#标签为0的数据
model_data.loc[model_data.loc[:,'SeriousDlqin2yrs']==0]
# 统计每个分箱中0和1的数量
# 这里使用了数据透视表的功能groupby,groupby需要同聚合函数一同使用
model_data.loc[model_data.loc[:,'SeriousDlqin2yrs']==0].groupby(by='qcut').count()
可以看到每一行的所有数都相同,这个数就是在此范围内0的总个数,不必纠结
#分箱中,标签为0的个数
#在上面看到一行所有值都相同,所以直接随便取一列就行
model_data.loc[model_data.loc[:,'SeriousDlqin2yrs']==0].groupby(by='qcut').count().loc[:,'SeriousDlqin2yrs']
#分箱中标签为1的个数
model_data.loc[model_data.loc[:,'SeriousDlqin2yrs']==1].groupby(by='qcut').count().loc[:,'SeriousDlqin2yrs']
#在这里正式建立变量,上面的只是讲解各个代码是什么意思
count_y0 = model_data.loc[model_data.loc[:,'SeriousDlqin2yrs']==0].groupby(by='qcut').count().loc[:,'SeriousDlqin2yrs']
count_y1 = model_data.loc[model_data.loc[:,'SeriousDlqin2yrs']==1].groupby(by='qcut').count().loc[:,'SeriousDlqin2yrs']
#zip会按照最短列来进行结合
[*zip([1,2,3],['a','b','c','d'])]
>[(1, 'a'), (2, 'b'), (3, 'c')]
updown
>array([ 21., 28., 31., 34., 36., 39., 41., 43., 45., 47., 48.,
50., 52., 54., 56., 58., 61., 64., 68., 74., 109.])
updown[1:]
>array([ 28., 31., 34., 36., 39., 41., 43., 45., 47., 48., 50.,
52., 54., 56., 58., 61., 64., 68., 74., 109.])
#通过updown来找到所有的分箱
[*zip(updown,updown[1:])]
#区间下界,区间上界,标签为0的个数,标签为1的个数
num_bins = [*zip(updown,updown[1:],count_y0,count_y1)]
#注意zip会按照最短列来进行结合
num_bins
假设以随便定义的数据a为例
#练习
a = [(21.0, 28.0, 0, 7346),
(28.0, 31.0, 3590, 6043),
(31.0, 34.0, 4043, 6759),
(34.0, 36.0, 0, 4724),
(36.0, 39.0, 5182, 0),
(39.0, 41.0, 3931, 5857)]
#练习
a = [(21.0, 28.0, 0, 7346),
(28.0, 31.0, 3590, 6043),
(31.0, 34.0, 4043, 6759),
(34.0, 36.0, 0, 4724),
(36.0, 39.0, 5182, 0),
(39.0, 41.0, 3931, 5857)]
for i in range(6):
#如果第一个组没有包含正样本或负样本,向后合并
if 0 in a[0][2:]:
a[0:2] = [(
a[0][0],
a[1][1],
a[0][2]+a[1][2],
a[0][3]+a[1][3]
)]
continue
"""
如果原本的第一组和第二组都没有包含正样本,或者都没有包含负样本,那即便合并之后,第一行的组也还是没有
包含两种样本
所以我们在每次合并完毕之后,还需要再检查,第一组是否已经包含了两种样本
这里使用continue跳出了本次循环,开始下一次循环,所以回到了最开始的for i in range(20), 让i+1
这就跳过了下面的代码,又从头开始检查,第一组是否包含了两种样本
如果第一组中依然没有包含两种样本,则if通过,继续合并,每合并一次就会循环检查一次,最多合并20次
如果第一组中已经包含两种样本,则if不通过,就开始执行下面的代码
"""
#已经确认第一组中肯定包含两种样本了,如果其他组没有包含两种样本,就向前合并
#此时的a已经被上面的代码处理过,可能被合并过,也可能没有被合并
#但无论如何,我们要在a中遍历,所以写成in range(len(a))
for k in range(1,len(a)):
if 0 in a[k][2:]:
a[k-1:k+1] = [(
a[k-1][0],
a[k][1],
a[k-1][2]+a[k][2],
a[k-1][3]+a[k][3]
)]
break
个人觉得这样会尽可能的减少无效循环的次数
a = [(21.0, 28.0, 0, 7346),
(28.0, 31.0, 3590, 6043),
(31.0, 34.0, 4043, 6759),
(34.0, 36.0, 0, 4724),
(36.0, 39.0, 5182, 0),
(39.0, 41.0, 3931, 5857)]
#判断第一个箱子是不是都不含有0个0或1的标签,如果有,就和下一个箱进行合并
for i in range(len(a)):
if 0 in a[0][2:]:
a[0:2] = [(
a[0][0],
a[1][1],
a[0][2]+a[1][2],
a[0][3]+a[1][3]
)]
else:
break
#在判断第一个箱子的时候,是不是合并了所有的箱子,如果合并了所有的箱子,
#flag就等于false,就代表不用担心还有剩下的箱子含有0个0或1标签,如果没有全部合并
#代表a的长度是大于1的,这个时候就可以进行接下来的箱体合并
flag = False
if len(a) > 1:
flag = True
#进行剩余箱体的判断及合并,如果发现有0个0或1的,就向前进行合并箱体
#使用了for..else,如果将a循环了一遍也没有需要合并的,就跳出循环
while flag:
for i in range(1,len(a)):
if 0 in a[i][2:]:
a[i-1:i+1] = [(
a[i-1][0],
a[i][1],
a[i-1][2]+a[i][2],
a[i-1][3]+a[i][3]
)]
break
else:
flag=False
#计算WOE和BAD RATE
#BAD RATE与bad%不是一个东西
#BAD RATE是一个箱中,坏的样本所占的比例 (bad/total)
#而bad%是一个箱中的坏样本占整个特征中的坏样本的比例
#计算WOE
def get_woe(num_bins):
#num_bins必须是二维
columns = ['min','max','count_0','count_1']
df = pd.DataFrame(num_bins,columns=columns)
#df.count_0取出某一列的所有值
df.loc[:,'total'] = df.count_0 + df.count_1 #一个箱的0和1的总数
df.loc[:,'percentage'] = df.total/df.total.sum() #一个箱中0和1的总数占全部箱0和1的总数的比例
df.loc[:,'bad_rate'] = df.count_1/df.total #一个箱中,坏的样本所占的比例 (bad/total)
df.loc[:,'good%'] = df.count_0/df.count_0.sum() # 一个箱中的好样本占整个特征中的好样本的比例
df.loc[:,'bad%'] = df.count_1/df.count_1.sum() #一个箱中的坏样本占整个特征中的坏样本的比例
df.loc[:,'WOE'] = np.log(df.loc[:,'good%']/df.loc[:,'bad%']) #计算出WOE值
return df
#计算IV值
def get_IV(df):
rate = df.loc[:,'good%']-df.loc[:,'bad%']
iv = np.sum(rate*df.WOE)
return iv
df = get_woe(num_bins)
df.head()
iv = get_IV(df)
iv
import matplotlib.pyplot as plt
import scipy.stats #卡方检验
首先来观察卡方检验返回的都是什么值
#分的箱体
num_bins_ = num_bins.copy()
num_bins_
num_bins_ = num_bins.copy()
#所求出来的woe,bad%,good%等都是根据count_0和count_1来得出的,所以他们之间一定存在线性关系
#所以对于分箱来说,取count_0和count_1来作为卡方检验的值
x1 = num_bins_[0][2:]
x2 = num_bins_[1][2:]
scipy.stats.chi2_contingency([x1,x2])
IV = []
axisx = []
#设置至少有两个箱子,箱子个数不可以少于2个
while len(num_bins_) > 2:
#储存卡方检验返回的p值
pvs = []
#获取 num_bins_两两之间的卡方检验的置信度(p值)或卡方值
#假设有n条数据,因为需要两两检验,所以只需要进行n-1此就行
for i in range(len(num_bins_)-1):
#在df中,所求出来的woe,bad%,good%等都是根据count_0和count_1来得出的,所以他们之间一定存在线性关系
#所以对于分箱来说,取count_0和count_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值越小,代表他们的相关性越强,合并p最小的两个组
i = pvs.index(min(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_)
#作为y轴
IV.append(get_IV(bins_df))
#作为x轴
axisx.append(len(num_bins_))
plt.plot(axisx,IV)
plt.xticks(axisx)
plt.xlabel('num_bins_ number')
plt.ylabel('IV')
plt.show()
#n是所需要分箱的个数
def get_bin(num_bins_,n):
while len(num_bins_) > n:
#储存卡方检验返回的p值
pvs = []
#获取 num_bins_两两之间的卡方检验的置信度(p值)或卡方值
#假设有n条数据,因为需要两两检验,所以只需要进行n-1此就行
for i in range(len(num_bins_)-1):
#在df中,所求出来的woe,bad%,good%等都是根据count_0和count_1来得出的,所以他们之间一定存在线性关系
#所以对于分箱来说,取count_0和count_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值越小,代表他们的相关性越强,合并p最小的两个组
i = pvs.index(min(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]
)]
return num_bins_
#通过曲线看,应该选择17个箱的时候
afterbins = get_bin(num_bins_,17)
afterbins
#获取将num_bins_进行求取woe等的dataframe
bins_df = get_woe(afterbins)
bins_df
#最终分箱后的woe的值
bins_df.WOE.values
#woe越大,代表优质客户就越多,就表在woe值高的箱体范围内,是不违规人群的分类
plt.plot(range(17),bins_df.WOE.values)
plt.show()
#获取IV值
get_IV(bins_df)
在这里使用的get_woe和get_IV都是上面定义好的
#计算WOE和BAD RATE
#BAD RATE与bad%不是一个东西
#BAD RATE是一个箱中,坏的样本所占的比例 (bad/total)
#而bad%是一个箱中的坏样本占整个特征中的坏样本的比例
#计算WOE
def get_woe(num_bins):
#num_bins必须是二维
columns = ['min','max','count_0','count_1']
df = pd.DataFrame(num_bins,columns=columns)
#df.count_0取出某一列的所有值
df.loc[:,'total'] = df.count_0 + df.count_1 #一个箱的0和1的总数
df.loc[:,'percentage'] = df.total/df.total.sum() #一个箱中0和1的总数占全部箱0和1的总数的比例
df.loc[:,'bad_rate'] = df.count_1/df.total #一个箱中,坏的样本所占的比例 (bad/total)
df.loc[:,'good%'] = df.count_0/df.count_0.sum() # 一个箱中的好样本占整个特征中的好样本的比例
df.loc[:,'bad%'] = df.count_1/df.count_1.sum() #一个箱中的坏样本占整个特征中的坏样本的比例
df.loc[:,'WOE'] = np.log(df.loc[:,'good%']/df.loc[:,'bad%']) #计算出WOE值
return df
#计算IV值
def get_IV(df):
rate = df.loc[:,'good%']-df.loc[:,'bad%']
iv = np.sum(rate*df.WOE)
return iv
提示:
#使用了两个中括号,在两个中括号里面写想要的列,就可以取出两个列
model_data[['SeriousDlqin2yrs','age']]
def graphforbestbin(dataframe, X, Y, n=5, q=20, graph=True):
'''
自动最优分箱函数,基于卡方检验的分箱
参数:
df: 需要输入的数据
X: 需要分箱的列名
Y: 分箱数据对应的标签 Y 列名
n: 保留分箱个数
q: 初始分箱的个数
graph: 是否要画出IV图像
区间为前开后闭 (]
'''
#获取需要进行分箱的特征列及标签列,例如对于age分箱,XY分别是age和SeriousDlqin2yrs标签
DF = dataframe[[X,Y]].copy()
'''
pd.qcut,基于分位数的分箱函数,本质是将连续型变量离散化
只能够处理一维数据。返回箱子的上限和下限
参数q:要分箱的个数
参数retbins=True来要求同时返回结构为索引为样本索引,元素为分到的箱子的Series
现在返回两个值:每个样本属于哪个箱子,以及所有箱子的上限和下限
duplicates='drop’表示若有重复区间则删除
'''
DF['qcut'], bins = pd.qcut(DF.loc[:,X], retbins=True, q=q, duplicates='drop')
count_0 = DF.loc[DF.loc[:,Y]==0].groupby('qcut').count()[Y]
count_1 = DF.loc[DF.loc[:,Y]==1].groupby('qcut').count()[Y]
num_bins = [*zip(bins,bins[1:],count_0,count_1)]
#==================================保证箱中都含有0和1===============================================================
#判断第一个箱子是不是都不含有0个0或1的标签,如果有,就和下一个箱进行合并
for i in range(len(num_bins)):
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]
)]
else:
break
#在判断第一个箱子的时候,是不是合并了所有的箱子,如果合并了所有的箱子,
#flag就等于false,就代表不用担心还有剩下的箱子含有0个0或1标签,如果没有全部合并
#代表num_bins的长度是大于1的,这个时候就可以进行接下来的箱体合并
flag = False
if len(num_bins) > 1:
flag = True
#进行剩余箱体的判断及合并
'''
举个例子,本来num_bins是5组,for i in range(len(num_bins))在第一次运行的时候就等于for i in
range(5)
range中输入的变量会被转换为数字,不会跟着num_bins的变化而变化,所以i会永远在[0,1,2,3,4]中遍历
进行合并后,num_bins变成了4组,已经不存在=4的索引了,但i却依然会取到4,循环就会报错
因此在这里,一旦if被触发,即一旦合并发生,我们就让循环被破坏,使用break跳出当前循环
循环就会回到最开始的while flag中
此时判断第一组是否有两种标签的代码不会被触发,但for i in range(len(num_bins))却会被重新运行
这样就更新了i的取值,循环就不会报错了
'''
while flag:
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:
flag=False
#=============================================卡方检验合并箱体======================================================
IV = []
axisx = []
#只是起到一个定义变量的作用
bins_df = 0
while len(num_bins) > n:
#储存卡方检验返回的p值
pvs = []
#获取 num_bins两两之间的卡方检验的置信度(p值)或卡方值
#假设有n条数据,因为需要两两检验,所以只需要进行n-1此就行
for i in range(len(num_bins)-1):
#在df中,所求出来的woe,bad%,good%等都是根据count_0和count_1来得出的,所以他们之间一定存在线性关系
#所以对于分箱来说,取count_0和count_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值越小,代表他们的相关性越强,合并p最小的两个组
i = pvs.index(min(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]
)]
#get_woe和get_IV都是上面定义好的
bins_df = get_woe(num_bins)
#作为y轴
IV.append(get_IV(bins_df))
#作为x轴
axisx.append(len(num_bins))
#================================是否画图============================================================
if graph:
plt.figure(figsize=[20,5])
plt.plot(axisx,IV)
plt.xticks(axisx)
plt.xlabel("number of box")
plt.ylabel('IV')
plt.show()
return bins_df
#model_data的列名
model_data.columns
for i in model_data.columns[1:-1]:
print(i)
graphforbestbin(model_data,i,"SeriousDlqin2yrs",n=2,q=20)
我们发现,不是所有的特征都可以使用这个分箱函数,比如说有的特征,像家人数量,就无法分出20组。于是我们将可以分箱的特征放出来单独分组,不能自动分箱的变量自己观察然后手写:
#首先查看这些画不出曲线的特征的取值及相应个数
fail_ = ['NumberOfTimes90DaysLate','NumberOfTime60-89DaysPastDueNotWorse',
'NumberOfDependents','NumberRealEstateLoansOrLines','NumberOfTime30-59DaysPastDueNotWorse']
for i in fail_:
print(i)
print(model_data.loc[:,i].value_counts())
#对于*[]的讲解
a = [1,2,3]
['b',*a,'b']
#*[]的作用就是将*[a]中,a的元素拿出来放到新的列表里
auto_col_bins = {'RevolvingUtilizationOfUnsecuredLines':13,
'age':17,
'DebtRatio':19,
'MonthlyIncome':19,
'NumberOfOpenCreditLinesAndLoans':14}
#不能使用自动分箱的变量
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],
"NumberOfDependents":[0,1,2,3]}
#保证区间覆盖使用 np.inf替换最大值,用-np.inf替换最小值
hand_bins = {k:[-np.inf,*v[:-1],np.inf] for k,v in hand_bins.items()}
hand_bins
接下来对所有特征按照选择的箱体个数和手写的分箱范围进行分箱:
#分箱的左右端点
bins_of_col = {}
for col in auto_col_bins:
bins_df = graphforbestbin(model_data,col
,'SeriousDlqin2yrs'
#使用字典的性质来取出每个特征所对应的箱的数量
,n=auto_col_bins[col]
,q=20,graph=False)
#将min和max转为集合,union是取两个集合的并集,bins_list就是各个区间的端点
bins_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
#合并手动分箱数据 ,将hand_bins所有的键值对更新到bins_of_col字典里
bins_of_col.update(hand_bins)
我们现在已经有了我们的箱子,接下来我们要做的是计算各箱的WOE,并且把WOE替换到我们的原始数据model_data中,因为我们将使用WOE覆盖后的数据来建模,我们希望获取的是”各个箱”的分类结果,即评分卡上各个评分项目的分类结果。
data = model_data.copy()
data.head()
#将age和标签单独提取出来
data = data[['age','SeriousDlqin2yrs']].copy()
data.head()
#函数pd.cut,可以根据已知的分箱间隔把数据分箱
#参数为 pd.cut(数据,以列表表示的分箱间隔)
#这个取值就是bins_of_col中age对应的区间
data['cut'] = pd.cut(data.loc[:,'age']
,[-np.inf,28.0,31.0,34.0,36.0,39.0,41.0,43.0,45.0,47.0,48.0,50.0,52.0,54.0,56.0,58.0,74.0,np.inf])
data.head()
#看不同分箱内标签0和1的个数
data.groupby('cut')['SeriousDlqin2yrs'].value_counts()
#unstack的作用是将数状转换成表结构
data.groupby('cut')['SeriousDlqin2yrs'].value_counts().unstack()
#有了上面的代码详细讲解,在这里就是再加上woe值一列
bins_df = data.groupby('cut')['SeriousDlqin2yrs'].value_counts().unstack()
bins_df['woe'] = np.log((bins_df[0]/bins_df[0].sum())/(bins_df[1]/bins_df[1].sum()))
bins_df
将上面的过程进行打包成为函数:
#df是表格,col是需要进行计算woe的特征,y是标签,bins是分箱的各个界限区间
def get_woe(df,col,y,bins):
#bins是分箱的左右端点
#cut是按照自己设定的进行分箱,qcut是等频分箱
df = df[[col,y]].copy()
df['cut'] = pd.cut(df.loc[:,col],bins)
bins_df = df.groupby('cut')[y].value_counts().unstack()
woe = np.log((bins_df[0]/bins_df[0].sum())/(bins_df[1]/bins_df[1].sum()))
df['woe'] = woe
return woe
#将所有特征的WOE存储到字典当中
woeall = {}
for col in bins_of_col:
woeall[col] = get_woe(model_data,col,"SeriousDlqin2yrs",bins_of_col[col])
woeall
#不希望覆盖掉原本的数据,创建一个新的DataFrame,索引和原始数据model_data一模一样
model_woe = pd.DataFrame(index=model_data.index)
model_woe
pd.cut(model_data.loc[:,'age'],bins_of_col['age'])
#映射woe值
#将原数据分箱后,按箱的结果把WOE结构用map函数映射到数据中
#map就是起到映射的作用,把woeall上面的值代替在model_data中相应的区间
pd.cut(model_data.loc[:,'age'],bins_of_col['age']).map(woeall['age'])
#对所有特征操作可以写成:
for col in bins_of_col:
model_woe[col] = pd.cut(model_data.loc[:,col],bins_of_col[col]).map(woeall[col])
#将标签补充到数据中
model_woe['SeriousDlqin2yrs'] = model_data['SeriousDlqin2yrs']
#这就是我们的建模数据了
model_woe.head()
我们只需要将已经计算好的WOE映射到测试集中去就可以了(也就是测试集直接使用使用训练集得出的分箱及woe值就可以):
#导入测试集
vail_data = pd.read_csv('vail_data.csv',index_col=0)
vail_data.head()
#同4中的步骤相同
vail_woe = pd.DataFrame(index=vail_data.index)
for col in bins_of_col:
vail_woe[col] = pd.cut(vail_data.loc[:,col],bins_of_col[col]).map(woeall[col])
vail_woe["SeriousDlqin2yrs"] = vail_data["SeriousDlqin2yrs"]
#测试集各个特征的woe值
vail_woe.head()
#训练集的各个特征woe
model_woe.head()
#对训练集和测试集的特征和标签分离
x = model_woe.iloc[:,:-1]
y = model_woe.iloc[:,-1]
vail_x = vail_woe.iloc[:,:-1]
vail_y = vail_woe.iloc[:,-1]
开始建模:
from sklearn.linear_model import LogisticRegression as LR
lr = LR().fit(x,y)
lr.score(vail_x,vail_y)
可以试着使用C和max_iter的学习曲线把逻辑回归的效果调上去。
c_1 = np.linspace(0.01,1,20)
c_2 = np.linspace(0.01,0.2,20)
score = []
for i in c_2:
lr = LR(solver='liblinear',C=i).fit(x,y)
score.append(lr.score(vail_x,vail_y))
plt.figure()
plt.plot(c_2,score)
plt.show()
score = []
for i in [1,2,3,4,5,6]:
lr = LR(solver='liblinear',C=0.025,max_iter=i).fit(x,y)
score.append(lr.score(vail_x,vail_y))
plt.figure()
plt.plot([1,2,3,4,5,6],score)
plt.show()
尽管从准确率来看,我们的模型效果属于一般,但我们可以来看看ROC曲线上的结果
ROC曲线将会在SVM有详细的讲解
#如果没有这个库就先安装库pip install scikit-plot
import scikitplot as skplt
#ROC曲线将会在SVM进行学习
#曲线下面就是AUC面积,AUC面积越大,模型捕捉少数类的效果就越好
vali_proba_df = pd.DataFrame(lr.predict_proba(vail_x))
skplt.metrics.plot_roc(vail_y, vali_proba_df,plot_micro=False,figsize=(6,6),plot_macro=False)
建模完毕,我们使用准确率和ROC曲线验证了模型的预测能力。接下来就是要将逻辑回归转换为标准评分卡了。评分卡中的分数,由以下公式计算:
S c o r e = A − B ∗ log ( o d d s ) Score=A-B*\log(odds) Score=A−B∗log(odds)
其实逻辑回归的结果取对数几率形式会得到 θ T x \theta^Tx θTx(详细请看逻辑回归概述) ,即我们的参数*特征矩阵,所以 log ( a d d s ) \log(adds) log(adds) 其实就是我们的参数。
两个常数可以通过两个假设的分值带入公式求出,这两个假设分别是:
例如,假设对数几率为 1 60 \frac{1}{60} 601时设定的特定分数为600,PDO=20,那么对数几率为 1 30 \frac{1}{30} 301时的分数就是620。带入以上线性表达式,可以得到:
#使用numpy来求解出AB值
B = 20/np.log(2)
A = 600 + B*np.log(1/60)
B,A
有了A和B,分数就很容易得到了。其中不受评分卡中各特征影响的基础分,就是将截距作为 log ( a d d s ) \log(adds) log(adds)带入公式进行计算,而其他各个特征各个分档的分数,也是将系数带入进行计算:
#基础分数
base_score = A - B*lr.intercept_
base_score
woeall
先以age为例
score_age = woeall["age"] * (-B*lr.coef_[0][6])
score_age
我们可以通过循环,将所有特征的评分卡内容全部一次性写往一个本地文件ScoreData.csv:
x.columns
对于逻辑回归的参数属性等,可以看逻辑回归参数属性接口列表
lr.coef_
file = 'ScoreData.csv'
with open(file,'w') as fdata:
#首先先将基础分数加到csv文件的第一行
fdata.write(f'base_score,{base_score}\n')
#coef_是对应特征的系数,不同特征的对应不同系数,在这里用来进行打分
for i,col in enumerate(x.columns):
#分数计算方法不用纠结,每个领域有自己的规则
score = woeall[col]*(-B*lr.coef_[0][i])
score.name = 'Score'
score.index.name = col
#mode='a'就可以追加写入数据
score.to_csv(file,header=True,mode="a")
对于制作评分卡这个案例中,计算各种评分的公式不用纠结,每个领域有自己的规则,在这里我们主要是理解分箱,会使用分箱,在机器学习中,数据处理的方式大大决定了模型的走向,以及各种性能