视频作者:菜菜TsaiTsai
链接:【技术干货】菜菜的机器学习sklearn【全85集】Python进阶_哔哩哔哩_bilibili
分训练集和测试集
from sklearn.model_selection import train_test_split
X = pd.DataFrame(X)
y = pd.DataFrame(y)
Xtrain,Xtest,Ytrain,Ytest = train_test_split(X,y,test_size=0.3,random_state=420)
model_data = pd.concat([Ytrain,Xtrain],axis=1)
model_data.index = range(model_data.shape[0])
model_data.columns = data.columns
vali_data = pd.concat([Ytest,Xtest],axis=1)
vali_data.index = range(vali_data.shape[0])
vali_data.columns = data.columns
model_data.to_csv(r'D:\ObsidianWorkSpace\SklearnData\model_data.csv',header=True,index=False)
vali_data.to_csv(r'D:\ObsidianWorkSpace\SklearnData\vali_data.csv',header=True,index=False)
分箱
前面提到过,我们要制作评分卡,是要给各个特征进行分档,以便业务人员能够根据新客户填写的信息为客户打分。因此在评分卡制作过程中,一个重要的步骤就是分箱。
分箱的本质,其实就是离散化连续变量,好让拥有不同属性的人被分成不同的类别(打上不同的分数),其实本质比较类似于聚类。
我们在分箱中要回答几个问题:
- 要分多少个箱子才合适
最开始我们并不知道,但是既然是将连续型变量离散化,想也知道箱子个数必然不能太多,最好控制在十个以下。而用来制作评分卡,最好能在4~5个为最佳。我们知道,离散化连续变量必然伴随着信息的损失,并且箱子越少,信息损失越大。为了衡量特征上的信息量以及特征对预测函数的贡献,银行业定义了概念Information value(IV):
IV
=
∑
i
=
1
N
(
g
o
o
d
%
−
b
a
d
%
)
×
WOE
i
\text{IV}=\sum\limits_{i=1}^{N}(good\%-bad\%)\times \text{WOE}_{i}
IV=i=1∑N(good%−bad%)×WOEi
IV是对整个特征来说的,IV代表的意义是我们特征上的信息量以及这个特征对模型的贡献
其中
N
N
N是这个特征上箱子的个数,
i
i
i代表每个箱子,good%是这个箱内的优质客户(标签为0的客户)站整个特征中所有优质客户的比例,bad%是这个箱子里的坏客户(标签为1的那些客户)占整个特征中所有坏客户的比例
WOE写作
WOE
i
=
ln
(
g
o
o
d
%
b
a
d
%
)
\text{WOE}_{i}=\ln \left(\frac{good\%}{bad\%}\right)
WOEi=ln(bad%good%)
我们一般认为 W O E = ln ( b a d % g o o d % ) \begin{aligned} WOE=\ln (\frac{bad\%}{good\%})\end{aligned} WOE=ln(good%bad%), I V = ( b a d % − g o o d % ) × W O E IV=(bad\%-good\%)\times WOE IV=(bad%−good%)×WOE
也就是与视频里正好相反,如果想用一般的方法,可以把 标 签 × − 1 + 1 标签\times -1 +1 标签×−1+1,来互换标签0,1
这是银行业中用来衡量违约概率的指标,中文叫做证据权重(weight of Evidence),本质其实就是优质客户比上坏客户的比例的对数。
WOE是对一个箱子来说的,WOE越大,代表了这个箱子里的优质客户越多。绝对值越高,表明该组别好坏客户的区隔程度越高;各组之间的WOE值差距应尽可能拉开并呈现由低至高的合理趋势
IV | 特征对预测函数的贡献 |
---|---|
<0.03 | 特征几乎不带有效信息,对模型没有贡献,这种特征可以被删除 |
0.03 ~ 0.09 | 有效信息很少,对模型的贡献度低 |
0.1 ~ 0.29 | 有效信息一般,对模型的贡献度中等 |
0.3 ~ 0.49 | 有效信息较多,对模型的贡献度较高 |
>=0.5 | 有效信息非常多,对模型的贡献超高并且可疑 |
可见,IV并非越大越好,我们想要找到IV的大小和箱子个数的平衡点。箱子越多,IV必然越小,因为信息损失会非常多,所以,我们会对特征进行分箱,然后计算每个特征在每个箱子数目下的WOE值,利用IV值的曲线,找出合适的分箱个数。
- 分箱要达成什么样的效果?
我们希望不同属性的人有不同的分数,因此我们希望在同一个箱子内的人的属性是尽量相似的,而不同箱子的人的属性是尽量不同的,即业界常说的”组间差异大,组内差异小“。对于评分卡来说,就是说我们希望一个箱子内的人违约概率是类似的,而不同箱子的人的违约概率差距很大,即WOE差距要大,并且每个箱子中坏客户所占的比重(bad%)也要不同。
我们可以使用卡方检验来对比两个箱子之间的相似性,如果两个箱子之间卡方检验的P值很大,则说明他们非常相似,那我们就可以将这两个箱子合并为一个箱子。
基于这样的思想,我们总结出我们对一个特征进行分箱的步骤:
- 把连续型变量分成一组数量较多的分类型变量,比如,将几万个样本分成100组,或50组
- 确保每一组中都要包含两种类别的样本,否则IV值会无法计算
- 对相邻的组进行卡方检验,卡方检验的P值很大的组进行合并,直到数据中的组数小于设定的N箱为止
- 让一个特征分别分成 [ 2 , 3 , 4.....20 ] [2,3,4.....20] [2,3,4.....20]箱,观察每个分箱个数下的IV值如何变化,找出最适合的分箱个数
- 分箱完毕后,我们计算每个箱的WOE值, b a d % bad\% bad%,观察分箱效果
这些步骤都完成后,我们可以对各个特征都进行分箱,然后观察每个特征的IV值,以此来挑选特征。
接下来,我们就以"age"为例子,来看看分箱如何完成
等频分箱
对应步骤中的第一步:把连续型变量分成一组数量较多的分类型变量,比如,将几万个样本分成100组,或50组。这里我们演示分20组
# 按照等频对需要分箱的列进行分箱
model_data["qcut"], updown = pd.qcut(model_data["age"],retbins=True,q=20)
# 在这里时让model_data新添加一列叫做“分箱”,这一列其实就是每个样本所对应的箱子
model_data["qcut"].value_counts()
---
(36.0, 39.0] 12646
(20.999, 28.0] 11773
(58.0, 61.0] 11393
……
(56.0, 58.0] 7870
(34.0, 36.0] 7494
(45.0, 46.0] 5699
Name: qcut, dtype: int64
updown # 21个元素,代表20个箱子的上下限
---
array([ 21., 28., 31., 34., 36., 39., 41., 43., 45., 46., 48., 50., 52., 54., 56., 58., 61., 64., 68., 74., 107.])
说一下qcut()
pd.qcut( ['x', 'q', 'labels=None', 'retbins: bool = False', 'precision: int = 3', "duplicates: str = 'raise'"], ) # x:一维数组或者Serise # q:表示分位数的整数或者数组。如果是正数,例如q=10,表示分成10个箱;如果是数组,注意这个数组的元素必须是分数,例如[0,0.25,0.5,0.75,1]也就是分四个箱,里面的元素表示数据的分位数 # rebins:布尔值。是否显示分箱的分界值。 # duplicates:看例子
链接:pd.qcut()数据分箱_一尺荷叶的博客-CSDN博客_pd.qcut()
duplicates=“drop”,常用于某一元素重复出现次数过多,导致分箱上下限为同一个数字,甚至不同分箱上下限为同一个数字,当等于“drop”时合并上下限相同的分箱,这会导致返回的分享个数小于设定的q值
pd.qcut([1,1,2],q=5,duplicates="drop",retbins=True) --- ([(0.999, 1.2], (0.999, 1.2], (1.6, 2.0]] Categories (3, interval[float64, right]): [(0.999, 1.2] < (1.2, 1.6] < (1.6, 2.0]], array([1. , 1.2, 1.6, 2. ]))
要求q=5即5个分箱,但是实际上我们最后只有3个分箱
# 统计分箱里0和1的数量
coount_y0 = model_data[model_data["SeriousDlqin2yrs"] == 0].groupby(by='qcut').count()["SeriousDlqin2yrs"]
coount_y1 = model_data[model_data["SeriousDlqin2yrs"] == 1].groupby(by='qcut').count()["SeriousDlqin2yrs"]
model_data[model_data["SeriousDlqin2yrs"] == 0].groupby(by='qcut').count().head()
# 注意结果是DataFrame,列标签是所有列
# num_bins值分别为每个区间的上界,下界,0出现的次数,1出现的次数
num_bins = [*zip(updown,updown[1:],coount_y0,coount_y1)]
# zip会按照最短的列表进行合并
# 这就是为什么updown,updown[1:]能被zip到一起,因为按照updown[1:]的长度来合并
df = pd.DataFrame(num_bins,columns=["min","max","count_0","count_1"])
df.head()
---
min max count_0 count_1
0 21.0 28.0 4243 7530
1 28.0 31.0 3571 5974
2 31.0 34.0 4075 6782
3 34.0 36.0 2908 4586
4 36.0 39.0 5182 7464
确保每个箱中都有0和1
对应步骤中的第二步:确保每一组中都要包含两种类别的样本,否则IV值会无法计算
for i in range(20):
# 这里range(20)只是为了保证循环足量,除非全部都是空箱,否则该循环结束的条件为break,用不到20次
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,0,0),(1,2,1,0)……]
# 我们每次合并完后,还需要重新检查第一组是否已经包含了两种样本
# 这里使用continue跳出了本次循环,开始下一次循环,所以回到了最开始的for i in range(20), 让i+1
# 就跳过了下面的代码,又从头开始检查,第一组是否包含了两种样本
# 如果第一组中依然没有包含两种样本,则if通过,继续合并,每合并一次就会循环检查一次,最多合并20次
# 如果第一组中已经包含两种样本,则if不通过,就开始执行下面的代码
# 已经确认第一组中肯定包含两种样本了,如果其他组没有包含两种样本,就向前合并
# 此时的num_bins已经被上面的代码处理过,可能被合并过,也可能没有被合并
# 但无论如何,我们要在num_bins中遍历,所以写成in range(len(num_bins))
for i in range(len(num_bins)):
if 0 in num_bins[i][2:]:
# 这里虽然有i-1,但是上面我们保证了第一个箱一定不含0,所以不会报错
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
# 这里的break,只有在if被满足的条件下才会被触发
# 也就是说,只有发生了合并,才会打断for i in range(len(num_bins))这个循环
# 因为我们在range(len(num_bins))中遍历,但合并发生后,len(num_bins)发生了改变,但循环却不会重新开始。也就是说,如果仍然用原来的i会出现越界错误
# 本来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跳出当前循环,循环就会回到最开始的for i in range(20)中
# 此时判断第一组是否有两种标签的代码不会被触发,但for i in range(len(num_bins))却会被重新运行,这样就更新了i的取值,循环就不会报错了
else:
break
定义WOE和IV函数
是第三步之前的准备
# 注意BAD RATE与bad%不是一个含义
# BAD RATE是一个箱中,坏的样本所占的比例 (bad/total)
# bad%是一个箱中的坏样本占整个特征中的坏样本的比例
df["total"] = df.count_0 + df.count_1 # 一个箱子中所有的样本数
df["precentage"] = df.total / df.total.sum() # 一个箱子中样本占所有样本的比例
df["bad_rate"] = df.count_1 /df.total
df["good%"] = df.count_0 / df.count_0.sum()
df["bad%"] = df.count_1 / df.count_1.sum()
df["woe"] = np.log(df["good%"] / df["bad%"])
rate = df["good%"] - df["bad%"]
iv_age = np.sum(rate * df.woe)
iv_age
---
0.3536513548464584
包装成函数
def get_woe(num_bins):
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["good%"] = df.count_0 / df.count_0.sum()
df["bad%"] = df.count_1 / df.count_1.sum()
df["woe"] = np.log(df["good%"] / df["bad%"])
return df
def get_iv(df):
rate = df["good%"] - df["bad%"]
iv = np.sum(rate * df.woe)
return iv
卡方检验,合并箱体,画出IV曲线
对应步骤中的第三步:对相邻的组进行卡方检验,卡方检验的P值很大的组进行合并,直到数据中的组数小于设定的N箱为止
# 卡方检验检验的是相邻的两个箱卡方的置信度,如果P值很大(卡方很小),说明两个箱相似,就可以合并两个箱
num_bins_ = num_bins.copy()
import matplotlib.pyplot as plt
import scipy
x1 = num_bins_[0][2:]
x2 = num_bins_[1][2:]
scipy.stats.chi2_contingency([x1,x2])
# 第一个元素是卡方值,第二个元素是P值,第三个元素是若标签与特诊完全独立的预测值
# (对于第三个元素,由于存在Yate校正,此时手算结果会与程序运算结果不一致,属于正常现象)
---
(4.2156240212309815,
0.04005333124426569,
1,
array([[4315.33080026, 7457.66919974],
[3498.66919974, 6046.33080026]]))
# 卡方值越小,认为两个箱越相似
# 极端的例子,如果卡方为0,即两个箱中的样本是成比例的
# 此时这两个箱显然最好是合并起来,并且这个操作信息损失也不会太大
while len(num_bins_) > 2:
pvs = []
# 获取num_bins_两两之间的卡方检验的置信度(或卡方值)
for i in range(len(num_bins_) - 1):
x1 = num_bins_[i][2:]
x2 = num_bins_[i+1][2:]
pv = scipy.stats.chi2_contingency([x1,x2])[1] # 取出P值
# 按照上面说的我们应该是要P值大的
pvs.append(pv)
i = pvs.index(max(pvs)) # 找出P值最大的两个箱
# 合并这两个箱
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])]
# 获取这次合并之后IV值的变化,用于画图
bins_df = get_woe(num_bins_)
axisx.append(len(num_bins_))
IV.append(get_iv(bins_df))
plt.figure()
plt.plot(axisx,IV)
plt.xticks(axisx)
plt.show()
用最佳分箱个数分箱,并验证分箱结果
对应步骤中的第四步:让一个特征分别分成
[
2
,
3
,
4.....20
]
[2,3,4.....20]
[2,3,4.....20]箱,观察每个分箱个数下的IV值如何变化,找出最适合的分箱个数
根据第三步的曲线找出第四步要选的箱个数n。
实际上,我们第三步循环到2个箱,此时我们仍可以后当时的代码,只要把2换成n,返回合并后的分箱即可
def get_bin(num_bins_,n):
while len(num_bins_) > n:
pvs = []
for i in range(len(num_bins_) - 1):
x1 = num_bins_[i][2:]
x2 = num_bins_[i+1][2:]
pv = scipy.stats.chi2_contingency([x1,x2])[1]
pvs.append(pv)
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])]
return num_bins_
num_bins_ = num_bins.copy()
afterbins = get_bin(num_bins_,6)
afterbins
---
[(21.0, 36.0, 14797, 24872),
(36.0, 54.0, 39070, 51429),
(54.0, 61.0, 15743, 12213),
(61.0, 64.0, 6968, 3192),
(64.0, 74.0, 13376, 4218),
(74.0, 107.0, 7737, 1393)]
分箱完毕,观察WOE
对应步骤中的第五步:分箱完毕后,我们计算每个箱的WOE值, b a d % bad\% bad%,观察分箱效果
bins_df = get_woe(afterbins)
bins_df['woe'] # 箱子尽可能要是单调的,如果有两个转折点一般来说分箱不太好
---
0 -0.523154
1 -0.278683
2 0.250059
3 0.776845
4 1.150265
5 1.710719
Name: woe, dtype: float64
# 这里woe是单调增加的,由负到正,说明效果不错
将选取最佳分箱个数的过程包装为函数
注意缩进,哭了,照着写都能写错
def graphforbestbin(DF,X,Y,n=5,q=20,graph=True):
"""
自动最优分箱函数,基于卡方检验的分箱
参数:
DF:需要输入的数据
X:需要分箱的列名,注意一次只能输入一列
Y:分箱数据对应的标签Y列名
n:保留分箱的个数
q:初始分箱的个数
graph:是否画出IV图像
"""
DF = DF[[X,Y]].copy()
DF["qcut"],bins = pd.qcut(DF[X],retbins=True,q=q,duplicates="drop")
coount_y0 = DF[DF[Y] == 0].groupby(by='qcut').count()[Y]
coount_y1 = DF[DF[Y] == 1].groupby(by='qcut').count()[Y]
num_bins = [*zip(bins,bins[1:],coount_y0,coount_y1)]
for i in range(q):
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
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
def get_woe(num_bins):
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["good%"] = df.count_0 / df.count_0.sum()
df["bad%"] = df.count_1 / df.count_1.sum()
df["woe"] = np.log(df["good%"] / df["bad%"])
return df
def get_iv(df):
rate = df["good%"] - df["bad%"]
iv = np.sum(rate * df.woe)
return iv
axisx = []
IV = []
while len(num_bins) > 2:
pvs = []
for i in range(len(num_bins) - 1):
x1 = num_bins[i][2:]
x2 = num_bins[i+1][2:]
pv = scipy.stats.chi2_contingency([x1,x2])[1]
pvs.append(pv)
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)
axisx.append(len(num_bins))
IV.append(get_iv(bins_df))
if graph:
plt.figure()
plt.plot(axisx,IV)
plt.xticks(axisx)
plt.xlabel("number of box")
plt.ylabel("IV")
plt.show()
对所有特征进行分箱选择
for i in model_data.columns[1:-1]:
print(i)
graphforbestbin(model_data,i,"SeriousDlqin2yrs",n=2,q=20)
我们发现,不是所有的特征都可以使用这个分箱函数,比如说有的特征,像家人数量,就无法分出20组。于是我们将可以分箱的特征放出来单独分组,不能自动分箱的变量自己观察然后手写
这里显然能自动分箱的很好看出来,我们先写出来
auto_col_bins = {"RevolvingUtilizationOfUnsecuredLines":6,
"age":5,
"DebtRatio":4,
"MonthlyIncome":3,
"NumberOfOpenCreditLinesAndLoans":5}
# 这些都是从图上用眼看的,如果数值选择在本例中选择左右都可以
对于不能自动分箱的变量自己观察然后手写,例如NumberOfTime30-59DaysPastDueNotWorse,其分箱图像为
观察下面的value_counts可以发现,有特别多的0,分20个箱导致了大多数箱的上下限为0,又因为我们在qcut中指定了duplicates=“drop”,这些上下限相同的分箱被合并删除,导致分箱数量极少,甚至只有1个或2个。我们又设定while len(num_bins) > 2:,也就是说只有箱数大于2,我们才会计算IV值,否则,IV值为空列表,因此画图为空白,即上图的情况
data["NumberOfTime30-59DaysPastDueNotWorse"].value_counts().sort_index()
---
0 125453
1 16032
2 4598
3 1754
4 747
5 342
6 140
7 54
8 25
9 12
10 4
11 1
12 2
13 1
Name: NumberOfTime30-59DaysPastDueNotWorse, dtype: int64
根据数值分布,我们考虑分箱界限为 [ 0 , 1 , 2 , 13 ] [0,1,2,13] [0,1,2,13]。其他特征重复这个过程,得到最后的结果
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:-1],np.inf] for k,v in hand_bins.items()}
hand_bins.items()
---
dict_items([('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])])
接下来对所有特征按照选择的箱体个数和手写的分箱范围进行分箱:
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)
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
合并手动分箱数据
bins_of_col.update(hand_bins)
bins_of_col
---
{'RevolvingUtilizationOfUnsecuredLines': [-inf,
0.09904372745676276,
0.29766696424041994,
0.46496043070279325,
0.9825517345741103,
0.9999999,
inf],
'age': [-inf, 36.0, 54.0, 61.0, 74.0, inf],
……}
计算各箱的WOE并映射到数据中
实际上我们已经有了分箱的边界,储存在bins_of_col,根据分箱的边界可以对数据分箱,然后统计不同分箱中标签得到WOE,再用该WOE值映射回原数据并进行建模即可
之前的分箱是为了找到分箱边界,这里是已经找到了边界,对数据进行分箱
data = model_data.copy() # 分训练集测试集时候的训练集
data = data[["age","SeriousDlqin2yrs"]]
data["cut"] = pd.cut(data.loc[:,"age"],bins_of_col["age"])
data.head()
---
age SeriousDlqin2yrs cut
0 53 0 (36.0, 54.0]
1 63 0 (61.0, 74.0]
2 39 1 (36.0, 54.0]
3 73 0 (61.0, 74.0]
4 53 1 (36.0, 54.0]
qcut(),分割样本集,使得每个箱子中含有样本的数量是相同的
cut(),根据指定的间隔来划分箱子(如果不传入列表而是传入数字,那么cut()的作用是,分割样本集,使得每个箱子的间距是相同的)
data.groupby("cut")["SeriousDlqin2yrs"].value_counts()
# 先将data按照cut分组,索引为cut,分组后只取出SeriousDlqin2yrs列,再对这一列进行统计值出现次数,增加一层SeriousDlqin2yrs所有取值的索引。因此会出现两层索引
---
cut SeriousDlqin2yrs
(-inf, 36.0] 1 24872
0 14797
(36.0, 54.0] 1 51429
0 39070
(54.0, 61.0] 0 15743
1 12213
(61.0, 74.0] 0 20344
1 7410
(74.0, inf] 0 7737
1 1393
Name: SeriousDlqin2yrs, dtype: int64
type(data.groupby("cut")["SeriousDlqin2yrs"].value_counts()) # 实际上还是一个Series,只不过索引有两层
---
pandas.core.series.Series
data.groupby("cut")["SeriousDlqin2yrs"].value_counts().unstack()
# unstack()把多层索引拆开,相应的Series可能变为DataFrame
---
SeriousDlqin2yrs 0 1
cut
(-inf, 36.0] 14797 24872
(36.0, 54.0] 39070 51429
(54.0, 61.0] 15743 12213
(61.0, 74.0] 20344 7410
(74.0, inf] 7737 1393
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
---
SeriousDlqin2yrs 0 1 woe
cut
(-inf, 36.0] 14797 24872 -0.523154
(36.0, 54.0] 39070 51429 -0.278683
(54.0, 61.0] 15743 12213 0.250059
(61.0, 74.0] 20344 7410 1.006120
(74.0, inf] 7737 1393 1.710719
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
---
SeriousDlqin2yrs 0 1 woe
cut
(-inf, 36.0] 14797 24872 -0.523154
(36.0, 54.0] 39070 51429 -0.278683
(54.0, 61.0] 15743 12213 0.250059
(61.0, 74.0] 20344 7410 1.006120
(74.0, inf] 7737 1393 1.710719
把以上过程包装成函数:
data = model_data.copy()
def get_woe(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()
woe = bins_df["woe"] = np.log((bins_df[0]/bins_df[0].sum())/(bins_df[1]/bins_df[1].sum()))
return woe
woeall = {}
for col in bins_of_col:
woeall[col] = get_woe(model_data,col,"SeriousDlqin2yrs",bins_of_col[col])
woeall
---
{'RevolvingUtilizationOfUnsecuredLines': cut
(-inf, 0.099] 2.201208
(0.099, 0.298] 0.666221
(0.298, 0.465] -0.125724
(0.465, 0.983] -1.072586
(0.983, 1.0] -0.483491
(1.0, inf] -2.030408
dtype: float64, 'age': cut
(-inf, 36.0] -0.523154
(36.0, 54.0] -0.278683
(54.0, 61.0] 0.250059
(61.0, 74.0] 1.006120
(74.0, inf] 1.710719
……}
相比于之前bins_of_col中只有分箱的边界,woeall中储存的是不同分箱的WOE值
接下来,把所有WOE映射到原始数据中
model_woe = pd.DataFrame(index=model_data.index) # 只有index的DataFrame
model_woe.head()
---
0
1
2
3
4
model_woe["age"] = pd.cut(model_data["age"],bins_of_col["age"]).map(woeall["age"])
pd.cut(model_data["age"],bins_of_col["age"]).head() # 就是之前将数据划分到分箱中
---
0 (36.0, 54.0]
1 (61.0, 74.0]
2 (36.0, 54.0]
3 (61.0, 74.0]
4 (36.0, 54.0]
Name: age, dtype: category
Categories (5, interval[float64, right]): [(-inf, 36.0] < (36.0, 54.0] < (54.0, 61.0] < (61.0, 74.0] < (74.0, inf]]
woeall["age"] # 一个Series,使用map将前面Series的值与map里的键匹配,然后匹配到的键对应的值替换前面Series对应位置的值
---
cut
(-inf, 36.0] -0.523154
(36.0, 54.0] -0.278683
(54.0, 61.0] 0.250059
(61.0, 74.0] 1.006120
(74.0, inf] 1.710719
dtype: float64
pd.cut(model_data["age"],bins_of_col["age"]).map(woeall["age"]).head()
---
0 -0.278683
1 1.006120
2 -0.278683
3 1.006120
4 -0.278683
Name: age, dtype: category
Categories (5, float64): [-0.523154 < -0.278683 < 0.250059 < 1.006120 < 1.710719]
对所有特征操作
for col in bins_of_col:
model_woe[col] = pd.cut(model_data[col],bins_of_col[col]).map(woeall[col])
经过这样的操作,本身的数据就变成了对应分箱的WOE值
将标签补充到数据中
model_woe["SeriousDlqin2yrs"] = model_data["SeriousDlqin2yrs"]
测试集进行类似操作
vali_woe = pd.DataFrame(index=vali_data.index)
for col in bins_of_col:
vali_woe[col] = pd.cut(vali_data[col],bins_of_col[col]).map(woeall[col])
vali_woe["SeriousDlqin2yrs"] = vali_data["SeriousDlqin2yrs"]
建模与模型验证
X = model_woe.iloc[:,:-1]
y = model_woe.iloc[:,-1]
vali_X = vali_woe.iloc[:,:-1]
vali_y = vali_woe.iloc[:,-1]
from sklearn.linear_model import LogisticRegression as LR
lr = LR().fit(X,y)
lr.score(vali_X,vali_y)
# 这里相比于视频下降了10%,实际上用视频中的代码执行了一次,结果是77%
# 猜测可能部分方法重写导致的
---
0.7697425098114291
看看ROC曲线上的结果
from sklearn.metrics import roc_curve,roc_auc_score
FPR,recall,thresholds = roc_curve(vali_y,lr.predict_proba(vali_X)[:,1],pos_label=1)
area = roc_auc_score(vali_y,lr.predict_proba(vali_X)[:,1])
plt.figure()
plt.plot(FPR,recall,color='red'
,label='ROC curve (area = %.2f)'%area)
plt.plot([0,1],[0,1],color='k',linestyle='--')
plt.xlim([-0.05,1.05])
plt.ylim([-0.05,1.05])
plt.xlabel('False Positive Rate')
plt.ylabel('Recall')
plt.legend(loc='lower right')
plt.show()