深入理解woe和iv

WOE(Weight of Evidence) 证据权重#

https://blog.csdn.net/kevin7658/article/details/50780391
https://zhuanlan.zhihu.com/p/80134853
https://www.cnblogs.com/hanxiaosheng/p/9831838.html
https://www.cnblogs.com/hanxiaosheng/p/9831964.html
https://blog.csdn.net/PbGc396Dwxjb77F2je/article/details/99687952

WOE的定义#

  • WOE是一种对原始自变量进行编码的格式,可以屏蔽极值增强鲁棒性。(树模型一般只对离散变量进行编码,对极值不敏感)
  • 要对一个变量进行WOE编码,需要首先把这个变量进行分组处理/离散化处理(等宽切割,等频切割,卡方分箱,或者利用决策树来切割)。
  • 分组后,对于第i组,WOE的计算公式如下:

woei=lnpyipni=lnpy1py0=ln(BadiBad/GoodiGood)=ln(BadiBad)ln(GoodiGood)woe_i = ln\frac{p_{yi}}{p_{ni}} = ln\frac{p_{y1}}{p_{y0}} = ln(\frac{Bad_i}{Bad}/\frac{Good_i}{Good}) = ln(\frac{Bad_i}{Bad})-ln(\frac{Good_i}{Good})

  • 其中:pyi为坏样本占所有坏样本的比例,py0好样本占所有好样本的比例;
  • Bad为坏样本总数,Badi为变量i对应的坏样本个数,Good为好样本总数,Goodi为变量i对应的好样本个数 ;
  • 将模型目标变量y为1记为违约用户(坏样本),对于目标变量为0记为正常用户(好样本)

Woe公式理解#

基础模式

woei=ln(BadiBad)ln(GoodiGood) woe_i = ln(\frac{Bad_i}{Bad})-ln(\frac{Good_i}{Good})

  • 即 WOE = ln (第i个分箱的坏人数 / 总坏人数) - ln (第i个分箱的好人数 / 总好人数)
  • 此时可以理解为:每个分箱里的坏人(响应)分布相对于好人(未响应)分布之间的差异性。

变换模式

woei=ln(BadiGoodi)ln(BadGood)woe_i = ln(\frac{Bad_i}{Good_i})-ln(\frac{Bad}{Good})

  • WOE = ln (第i个分箱的坏人数 / 第i个分箱的好人数) - ln (总坏人数 / 总好人数)
  • 此时可以理解为:每个分箱里的坏好比(Odds)相对于总体的坏好比之间的差异性。

WOE回顾:#

  • 当前分组中,差异越大,响应的比例越大,WOE值越大
  • 反应的是特征的重要性,woe的绝对值越大,说明越重要。
  • 当前分组WOE的正负,由当前分组响应和未响应的比例,与样本整体响应和未响应的比例的大小关系决定,当前分组的比例小于样本整体比例时,WOE为负当前分组的比例大于整体比例时,WOE为正,当前分组的比例和整体比例相等时,WOE为0
  • WOE的取值范围是全体实数。(所以就不方便,需要IV缩放)
  • WOE其实描述了变量当前这个分组,对判断个体是否会响应(或者说属于哪个类)所起到影响方向和大小,当WOE为正时,变量当前取值对判断个体是否会响应起到的正向的影响,当WOE为负时,起到了负向影响。而WOE值的大小,则是这个影响的大小的体现
  • 做完woe之后,LR系数不再代表特征的重要程度。
  • woe后LR的时候要保证系数全都是正数!
    • woe的符号代表特征对模型贡献的方向,系数如果不是正数就会改变这个方向。
    • 但是做BiVar的时候已经分析了这个woe特征的贡献方向,如果LR再负数会扭曲推翻之前BiVar的分析。
  • 优点:数值型转化为WOE可以增强鲁棒性屏蔽极值的影响(极小值和极大值也被分组了)
  • 但是树模型对极值不敏感,只用处理字符型即可

note:如果特征做了WOE,那么LR的系数不能代表特征重要性权重。(WOE绝对值大小已经是特征重要性了,LR的系数仅仅是拟合系数而已) woe后**如果是LR的时候要保证系数全都是正数! **woe的符号代表特征对模型贡献的方向,系数如果不是正数就会改变这个方向。 但是做BiVar的时候已经分析了这个woe特征的贡献方向,如果LR再负数会扭曲推翻之前BiVar的分析。

  • 核心——分箱逻辑:

  • 实现WOE最重要的是分箱逻辑,不同的分箱会带来不同的WOE。金融常使用“基于负样本占比差异最大化”原则来分箱

    • 一般是5箱内最好,通常最多不超过10箱
    • 每一箱的负样本占比差值尽可能大(箱合并原则)
    • 每一箱的样本量不少于总体5%(不要太小,不要小于三五百个样本)
    • 通过控制划分后的总箱数,来迭代进行分箱合并

IV值:可以认为是WOE的加权#

某个分箱的IV值:

IVi=(pyipni)WOEi=(BadiBadtGoodiGoodt)WOEi=(BadiBadtGoodiGoodt)ln(BadiBadt/GoodiGoodt)IV_i =(p_{yi}-p_{ni}) * WOE_i= (\frac{Bad_i}{Bad_t}-\frac{Good_i}{Good_t}) * WOE_i = (\frac{Bad_i}{Bad_t}-\frac{Good_i}{Good_t}) * ln(\frac{Bad_i}{Bad_t}/\frac{Good_i}{Good_t})

有了一个变量各分组的IV值,我们就可以计算整个变量的IV值:

IV=inIViIV = \sum_i^n{IV_i}

n是分箱的数量

  • 对于变量的一个分组,这个分组的响应和未响应的比例与样本整体响应和未响应的比例相差越大,IV值越大,否则,IV值越小;
  • 极端情况下,当前分组的响应和未响应的比例和样本整体的响应和未响应的比例相等时,IV值为0;
  • IV值的取值范围是[0,+∞) ,且,当当前分组中只包含响应客户或者未响应客户时,IV = +∞。
  • 故可以计算多个特征的IV值,按照从大到小排序来决定采用哪些特征更容易响应。(类似信息增益或者基尼指数的感觉)
  • IV比如要大于0.05才比较好用
  • 谨慎的时候会要求IV大于0.02就可以先留着,也就是说IV在0.02-0.5之间
  • 超过0.5的特征会被直接拿去作为策略------------->IV太大的值可能会把模型其他特征的信息覆盖掉,也可能会造成过拟合。(如果这个特征以后抖动,造成线上效果波动)

计算woe和IV的步骤#

  • step 1. 对于连续型变量,进行分箱(binning),可以选择等频、等距,或者自定义间隔;对于离散型变量,如果分箱太多,则进行分箱合并。
  • step 2. 统计每个分箱里的好人数(bin_goods)和坏人数(bin_bads)
  • step 3. 分别除以总的好人数(total_goods)和坏人数(total_bads),得到每个分箱内的边际好人占比(margin_good_rate)和边际坏人占比(margin_bad_rate)。
  • step 4. 计算每个分箱里的WOE [公式]
  • step 5. 检查每个分箱(除null分箱外)里woe值是否满足单调性(bivar),若不满足,返回step1。注意⚠️:null分箱由于有明确的业务解释,因此不需要考虑满足单调性。
  • step 6. 计算每个分箱里的IV,最终求和,即得到最终的IV。
    备注:好人 = 正常用户,坏人 = 逾期用户

计算注意点#

  • 分箱时需要注意样本量充足,保证统计意义。

  • 若相邻分箱的WOE值相同(非常相近),则将其合并为一个分箱。

  • 当一个分箱内只有好人或坏人时(会出现∞),可对WOE公式进行修正如下:

    Woei=ln(Badi+0.5Badt+0.5/GoodiGoodt)Woe_i = ln(\frac{Bad_i+0.5}{Bad_t+0.5}/\frac{Good_i}{Good_t})

  • 在实践中,我们还需跨数据集检验WOE分箱的单调性。如果在训练集上保持单调,但在验证集和测试集上发生翻转而不单调,那么说明分箱并不合理,需要再次调整。(BIVAR)

  • 或者当分箱中只有好人或坏人的时候,也可以这么做:

    • 如果可能,直接把这个分组做成一个规则,作为模型的前置条件或补充条件;(即不允许这种分箱存在)
    • 重新对变量进行离散化或分组,使每个分组的响应比例都不为0且不为100%,尤其是当一个分组个体数很小时(比如小于100个),强烈建议这样做,因为本身把一个分组个体数弄得很小就不是太合理。
    • 如果上面两种方法都无法使用,建议人工把该分组的响应数和非响应的数量进行一定的调整。如果响应数原本为0,可以人工调整响应数为1,如果非响应数原本为0,可以人工调整非响应数为1.(或者按照上面进行修正,分子分母都加0.5)

WOE和IV的比较----为什么不用WOE,而是用IV值#

变量各分组的WOE和IV都隐含着这个分组对目标变量的预测能力这样的意义,但是有以下问题:
1. 各个组的WOE有正有负

解释:

  • 假设构造一个$ WOE=\sum_i^n{WOE_i} ,那么因为里面的WOE_i$有正有负,所以求和不好表征。

2.每个组的WOE没有考虑到这个各个组在总体的占比
解释:

  • 即使构造一个WOE=inWOEiWOE=\sum_i^n{|WOE_i|}规避上面的负数问题,但是每个组WOEiWOE_i的信息含量(泛化能力?)是不相同的,比如某个组WOEiWOE_i很高但是这个组只有很少的样本,把他直接和另外一个很多样本但很低的WOEjWOE_j相加是很不合适的。

image-20200522143533847

假设某特征A分两组,从这个表我们可以看到,变量取1时,响应比达到90%,对应的WOE很高,但对应的IV却很低,原因就在于IV在WOE的前面乘以了一个系数(pyipni)(p_{yi}-p_{ni})

  • 而这个系数很好的考虑了这个分组中样本占整体样本的比例,比例越低,这个分组对变量整体预测能力的贡献越低。
  • 相反,如果直接用WOE的绝对值加和,会得到一个很高的指标,这是不合理的。

通用WOE计算实现#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# -*- coding: utf-8 -*-

import math

import pandas as pd
import numpy as np

from pandas import DataFrame

from pandas.core.dtypes import dtypes
from pandas.core.dtypes.common import is_numeric_dtype
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeRegressor

# 自定义实现的离散型变量woe
class charWoe(object):

def __init__(self, datases: dict, dep, weight, vars: list):
# 数据字典{'dev':xxx,'val':xxx,'off':xxx} 训练集,测试集,时间外样本集 3个dataframe
self.datases = datases
self.devf = datases.get('dev', '')
self.valf = datases.get('val', '')
self.offf = datases.get('off', '')
self.dep = dep # 标签
self.weight = weight # 样本权重
self.vars = vars # 特征名
self.nrows, self.ncols = self.devf.shape # 样本数,特征数

def char_woe(self):
# 得到每一类样本的个数,且加入平滑项是的bad和good都不为0
dic = dict(self.devf.groupby(self.dep).size()) # 根据标签去group,变成{1:xxx,0:yyy}字典
good = dic.get(0, 0) + 1e-10 # 平滑防止组内为0,计算失败
bad = dic.get(1, 0) + 1e-10
# 对每一个特征进行处理
for col in self.vars:
# df[[sex,bad]].groupby(['sex','bad']).size() 会得到一个series,
# 直接转成字典:{(男, 0): 10553, (男, 1): 518, (女, 0): 233, (女, 1): 3}
# key的第一个代表特征值,第二个代表标签值
data = dict(self.devf[[col, self.dep]].groupby([col, self.dep]).size())
'''
特征值+分类组合超过100的时候,跳过当前取值
假设二分类,dep是0,、1,则这个特征只能有50个特征值
{(col特征值A,0):25,(col特征值A,1):10,(col特征值B,0):33,(col特征值B,1):21...}
因为特征值过多时,WOE分箱效率低,建议进行特征截断
出现频率过低的特征就统一赋值,放到同一个箱里
'''
if len(data) > 100:
print(col, '有太多的特征值,建议手动进行特征截断,即将跳过此特征...')
continue
# 打印特征取取值个数
print('特征【%s】的取值个数是【%d】' % (col, len(data)))
dic = dict()
# {(男, 0): 10553, (男, 1): 518, (女, 0): 233, (女, 1): 3}
# key的第一个代表特征值,第二个代表标签值
for (k, v) in data.items():
fea_value, dp = k # 拿出key中的特征值和标签(fea_value=男,dp=0,v=10553)
dic.setdefault(fea_value, {}) # 给对应key设置为一个空字典(如果没有找到的话,找到的话说明之前已经设置过了)
#{(男, 0): 10553, (男, 1): 518} ==> {男:{1 = 518,0 = 10553} , 女:{...} }
dic[fea_value][int(dp)] = v # 字典中嵌套字典
for(k, v) in dic.items(): # 计算cnt和badrate
# 循环上面的嵌套字典,k=男,v={1 = xxx,0 = yyy}。
# 拿出内部嵌套的字典k1 = 1 v1=xxx,生成--->:{‘男’:{ '0': 10553, '1': 518}}
dic[k] = {str(int(k1)):v1 for (k1, v1) in v.items()}
# 所有正负样本的和v.values(): [10553,518]
dic[k]['cnt'] = sum(v.values())
# 4舍5入求bad_rate
bad_rate = round(v.get(1,0)/dic[k]['cnt'], 5)
dic[k]["bad_rate"] = bad_rate
# 利用定义的函数进行合并分箱。 dic={'男': {'cnt': xxx, '0': yy, '1': zz, 'bad_rate': 0.xx}, 'B': {'cnt': xxx, '0': yyy, '1': zz, 'bad_rate': 0.zz}}
dic = self.combine_box_char(dic)
# 对每个特征计算WOE和IV值
for (k,v) in dic.items():
a = v.get('0', 1) / good+1e-10
b = v.get('1', 1) / bad+1e-10
dic[k]['Good'] = v.get('0',0)
dic[k]['Bad'] = v.get('1',0)
# 下面两个是 a/b 还是 b/a? 按照定义应该是ln(pi/pn) = ln(p_bad/p_good)?
dic[k]['woe'] = round(math.log(b/a),5)
dic[k]['iv'] = round((b-a)*dic[k]['woe'],5)
'''
按照分箱后的点进行分割,
计算得到每一个特征值的WOE值,
将原始特征名加上'_woe'后缀,并赋予WOE值。
'''
for (klis, v) in dic.items():
# 把分箱合并后的key切开
for k in str(klis).split(','):
# 数字类型处理一下
if is_numeric_dtype(self.devf[col]):
k = float(k) if '.' in k else int(k)
# 训练集进行替换
self.devf.loc[self.devf[col] == k, "%s_woe" % col] = v["woe"]
self.devf.loc[self.devf[col] == k, "%s_iv" % col] = v["iv"]
# 测试集进行替换
if not isinstance(self.valf, str):
self.valf.loc[self.valf[col] == k,"%s_woe" % col] = v["woe"]
self.valf.loc[self.valf[col] == k, "%s_iv" % col] = v["iv"]
# 跨时间验证集进行替换
if not isinstance(self.offf, str):
self.offf.loc[self.offf[col] == k,"%s_woe" % col] = v["woe"]
self.offf.loc[self.offf[col] == k, "%s_iv" % col] = v["iv"]
# 返回新的字典,其中包含三个数据集。
return {"dev": self.devf, "val": self.valf, "off": self.offf}

def combine_box_char(self, dic):
'''
实施两种分箱策略(规则):
1.不同箱之间负样本占比(bad_rate)差异最大化。----各个特征值按照badrate从小到大排序,分别用后面一个减去前面每一个,计算badrate差值。找到差值最小的两箱合并之
2.每一箱的样本量不能过少。----当有某箱样本小于总样本的0.05,或总箱数>5的时候,还是按照badrate差异最大化原则:按badrate排序后,把最小的一箱和前后比较,与差值较小的一箱合并
:param dic: 等待分箱的数据
:return:
'''
# 首先合并至10箱以内。按照每一箱负样本占比差异最大化原则进行分箱。----各个特征值按照badrate从小到大排序,分别用后面一个减去前面每一个,计算badrate差值。找到差值最小的两箱合并之
while len(dic) >= 10:
# 拿出所有的特征和badrate,k是特征值,v['bad_rate']是负样本占比
bad_rate_dic = {k:v['bad_rate'] for (k,v) in dic.items()}
# 按照负样本占比排序。因为离散型变量是无序的(比如学历、渠道类型)
# 可以直接写成负样本占比递增的形式。(所有的dict按照value升序排序)
# 得到一堆tuple的list,是(特征值,bad_rate)的一个list
bad_rate_sorted = sorted(bad_rate_dic.items(), key=lambda x: x[1])
# 计算每两箱之间的负样本占比差值。
bad_rate_diff = [bad_rate_sorted[i + 1][1] - bad_rate_sorted[i][1] for i in range(len(bad_rate_sorted) - 1)]
# 找到差值最小的那个,准备将其进行合并。
min_diff_index = bad_rate_diff.index(min(bad_rate_diff))
# 找到k1和k2,即差值最小的两箱的key.
k1, k2 = bad_rate_sorted[min_diff_index][0], bad_rate_sorted[min_diff_index + 1][0]
# 得到重新划分后的字典,箱的个数比之前少一 直接改了dic,给他里面插入一个新的分箱。key是两个key的组合!
dic["%s,%s" % (k1, k2)] = dict()
# 重新统计新箱的正负样本数(合并两个key的)
dic["%s,%s" % (k1, k2)]["0"] = dic[k1].get("0", 0) + dic[k2].get("0", 0)
dic["%s,%s" % (k1, k2)]["1"] = dic[k1].get("1", 0) + dic[k2].get("1", 0)
# 重新统计新箱的cnt
dic["%s,%s" % (k1, k2)]["cnt"] = dic[k1]["cnt"] + dic[k2]["cnt"]
# 重新计算新分箱的bad_rate
dic["%s,%s" % (k1, k2)]["bad_rate"] = round(dic["%s,%s" % (k1, k2)]["1"] / dic["%s,%s" % (k1, k2)]["cnt"],5)
# 删除之前两个老的分箱
del dic[k1], dic[k2]
'''
结束循环后,箱的个数应该少于10。
下面实施第二种分箱策略规则:每个分箱的样本不能太少!
将样本数量少的箱合并至其他箱中,以保证每一箱的样本数量不要太少。
'''
# 找出最少样本数的分箱
min_cnt = min([v['cnt'] for (k, v) in dic.items()])
# 当样本数量小于总样本的5%或者总箱的个数大于5的时候,对箱进行合并 【这里的5% 和 5是经验值】
while min_cnt < self.nrows*0.05 or len(dic) > 5:
# 可能找到多个符合min_cnt的list,取第一个
min_key = [k for (k,v) in dic.items() if v['cnt'] == min_cnt][0]
bad_rate_dic = {k:v['bad_rate'] for (k,v) in dic.items()}
# 根据bad_rate升序
bad_rate_sorted = sorted(bad_rate_dic.items(),key=lambda x:x[1])
keys = [item[0] for item in bad_rate_sorted]
min_key_index = keys.index(min_key)
'''
不能直接把样本数最小的两个分箱合并,因为
同样想保持合并后箱之间的负样本占比差异最大化。
由于箱的位置不同,按照三种不同情况进行分类讨论。
'''
# 如果是第一箱、第二箱
if min_key_index == 0:
k1,k2 = keys[:2]
elif min_key_index == len(keys)-1:
# 如果是最后一箱,和倒数第二箱合并
k1,k2 = keys[-2:]
else:
# 如果是中间箱,前后相比和bad_rate值相差最小的箱合并
# 和前面的比
bef_bad_rate = dic[min_key]['bad_rate'] - dic[keys[min_key_index-1]]['bad_rate']
# 后面的当前比(keys是按照bad_rate升序的,不减出负数)
aft_bad_rate = dic[keys[min_key_index+1]]['bad_rate'] - dic[min_key]['bad_rate']
if bef_bad_rate <= aft_bad_rate:
k1,k2 = keys[min_key_index-1], min_key
else:
k1,k2 = min_key, keys[min_key_index+1]
# 找到k1,k2后合并之,同上
# 新增一个合并后的分箱
dic["%s,%s" % (k1, k2)] = dict()
# 重新计算cnt,bad_rate,正负样本数
dic["%s,%s" % (k1, k2)]["0"] = dic[k1].get("0", 0) + dic[k2].get("0", 0)
dic["%s,%s" % (k1, k2)]["1"] = dic[k1].get("1", 0) + dic[k2].get("1", 0)
dic["%s,%s" % (k1, k2)]["cnt"] = dic[k1]["cnt"] + dic[k2]["cnt"]
dic["%s,%s" % (k1, k2)]["bad_rate"] = round(dic["%s,%s" % (k1, k2)]["1"] /dic["%s,%s" % (k1, k2)]["cnt"], 5)
# 删除旧的分箱
del dic[k1], dic[k2]
# 重新计算当前最小的箱的样本个数,进入下次循环继续合并分箱
min_cnt = min([v["cnt"] for v in dic.values()])
return dic