详解逻辑回归与评分卡-用逻辑回归制作评分卡-分箱【菜菜的sklearn课堂笔记】

时间:2022-12-20 11:19:30

视频作者:菜菜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): $$ \text{IV}=\sum\limits_{i=1}^{N}(good%-bad%)\times \text{WOE}{i} $$ IV是对整个特征来说的,IV代表的意义是我们特征上的信息量以及这个特征对模型的贡献 其中$N$是这个特征上箱子的个数,$i$代表每个箱子,good%是这个箱内的优质客户(标签为0的客户)站整个特征中所有优质客户的比例,bad%是这个箱子里的坏客户(标签为1的那些客户)占整个特征中所有坏客户的比例 WOE写作 $$ \text{WOE}{i}=\ln \left(\frac{good%}{bad%}\right) $$

我们一般认为$\begin{aligned} WOE=\ln (\frac{bad%}{good%})\end{aligned}$,$IV=(bad%-good%)\times WOE$ 也就是与视频里正好相反,如果想用一般的方法,可以把$标签\times -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值很大,则说明他们非常相似,那我们就可以将这两个箱子合并为一个箱子。

基于这样的思想,我们总结出我们对一个特征进行分箱的步骤:

  1. 把连续型变量分成一组数量较多的分类型变量,比如,将几万个样本分成100组,或50组
  2. 确保每一组中都要包含两种类别的样本,否则IV值会无法计算
  3. 对相邻的组进行卡方检验,卡方检验的P值很大的组进行合并,直到数据中的组数小于设定的N箱为止
  4. 让一个特征分别分成$[2,3,4.....20]$箱,观察每个分箱个数下的IV值如何变化,找出最适合的分箱个数
  5. 分箱完毕后,我们计算每个箱的WOE值,$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()

详解逻辑回归与评分卡-用逻辑回归制作评分卡-分箱【菜菜的sklearn课堂笔记】

用最佳分箱个数分箱,并验证分箱结果

对应步骤中的第四步:让一个特征分别分成$[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值,$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,其分箱图像为 详解逻辑回归与评分卡-用逻辑回归制作评分卡-分箱【菜菜的sklearn课堂笔记】

观察下面的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]$。其他特征重复这个过程,得到最后的结果

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

详解逻辑回归与评分卡-用逻辑回归制作评分卡-分箱【菜菜的sklearn课堂笔记】