离散特征在数据挖掘的过程中具有重要作用,因此特征离散化是构建特征工程的一个很常见、也很重要的环节。
卡方分箱作为最经典的离散化方法之一,最近做项目需要用到时,却发现这么经典的功能python竟然没有官方的封装库。找了许多资料,感觉讲的都比较杂(一会chiMerge,一会chi2,一会单调性检验 O__O”… ),看的怀疑人生。最后实在不得已,只能翻出原论文 ChiMerge: Discretization of Numeric Attributes 出来拜读一下。
看完论文后发现最原始的卡方分箱思想还是挺简单的,只是网上很多资料讲的层次不清晰,让很多初学者看昏了头。因此写篇博客记录复现时的一些想法和踩过的坑。
首先,介绍一下什么是分箱。分箱是将连续变量离散化,多状态的离散变量合并成少状态的过程。这句话里包含两个要点,第一点:分箱的对象可以是连续变量,也可以是离散变量。第二点:分箱的目的是将变量的可取值变少(更便于分析)。目前主流的分箱方法可以分为两大类:1)自底向上的基于合并(merge)机制的方法,如卡方分箱;2)自上向下的基于分割(split)机制的方法,如基于决策树的分箱、bestKS分箱。后续,博主将一一开设博客介绍这些方法,敬请期待。
前面说过,卡方分箱是典型的基于合并机制的自底向上离散化方法。其基于如下假设:如果两个相邻的区间具有非常类似的类分布,则这两个区间可以合并;否则,它们应当保持分开。此处衡量分布相似性的指标就是卡方值。卡方值越低,类分布的相似度越高。
因此,ChiMerge分箱的主要思想归结为一句话就是:将具有最小卡方值的相邻区间合并在一起,直到满足确定的停止准则。其通用流程如下:
这里面包含四个关键点:
1. 离散变量如何排序:大部分关于卡方分箱的介绍都是针对连续变量,对于离散变量,应该如何处理
2. 卡方值计算(坑最深的地方):如何计算相邻两项的卡方值
3. 区间合并:如何合并卡方值最小的两项
4. 停止条件:何时结束循环,停止分箱
下面我们将具体讨论这些问题。
卡方分箱的第一步即对数据排序。对于连续变量,直接根据变量数值大小排序即可。对于离散变量,由于取值不存在大小关系,无法直接排序。这里一般采用的排序依据是:正例样本的比例,即待分箱变量每个取值中正例样本的比重,对应代码中的pos_ratio属性。
那么具体如何排序呢?我们前面提到过,卡方分箱是基于合并机制的离散化方法。因此,初始的分箱状态为:将待分箱变量的每个取值视为一个单独的箱体,后续分箱的目的就是将这些箱体合并为若干个箱体。首先,我们统计待分箱变量的可选取值,以及各个取值的正负样本数量(count),然后判断变量类型确定排序依据。
代码如下:其中,var_name_bf 表示需要分箱的变量,函数返回排序后的待分箱变量的统计分布,包括样本取值,正例样本,负例样本。
def dsct_init(data, var_name_bf, var_name_target, feature_type):
"""
特征离散化节点初始化:统计各取值的正负样本分布 [正例样本个数,负例样本个数] 并排序
:param data: DataFrame 输入数据
:param var_name_bf: str 待分箱变量
:param var_name_target: str 标签变量(y)
:param feature_type: 特征的类型:0(连续) 1(离散)
:return: DataFrame 排好序的各组中正负样本分布 count
"""
# 统计待离散化变量的取值类型(string or digits)
data_type = data[var_name_bf].apply(lambda x: type(x)).unique()
var_type = True if str in data_type else False # 实际取值的类型:false(数字) true(字符)
# 是否需要根据正例样本比重排序,True:需要,False:不需要
# 0(连续) 1(离散)
# false(数字) 0 0(离散有序)
# true(字符) × 1(离散无序)
if feature_type == var_type:
ratio_indicator = var_type
elif feature_type == 1:
ratio_indicator = 0
print("特征%s为离散有序数据,按照取值大小排序!" % (var_name_bf))
elif feature_type == 0:
exit(code="特征%s的类型为连续型,与其实际取值(%s)型不一致,请重新定义特征类型!!!" % (var_name_bf, data_type))
# 统计各分箱(group)内正负样本分布[累计样本个数,正例样本个数,负例样本个数]
count = pd.crosstab(data[var_name_bf], data[var_name_target])
total = count.sum(axis=1)
# 排序:离散变量按照pos_ratio排序,连续变量按照index排序
if ratio_indicator:
count['pos_ratio'] = count[1].sum(axis=1) * 1.0 / total #计算正例比例
count = count.sort_values('pos_ratio') #离散变量按照pos_ratio排序
count = count.drop(columns = ['pos_ratio'])
else:
count = count.sort_index() # 连续变量按照index排序
return count, ratio_indicator
需要注意的是,如果待分箱变量为离散变量,该方法只能使用于二分类模型。因为计算pos_ratio时,要求 y ∈ [ 0 , 1 ] y \in [0,1] y∈[0,1]。当然,这里可以根据个人需要调整pos_ratio的计算方式,以适应多分类问题。
大多数介绍卡方分箱的文章都没有具体解释相邻区间的卡方值如何计算。在原论文中对于卡方值计算也比较简略,这部分将着重讨论这一内容。首先,给出卡方值的计算公式,如下图所示(左边:数据,右边:对应的卡方值计算公式)。从公式来看,卡方值的计算其实并不复杂。对于四联表中的每一项,分别计算每一项的期望值(分母部分),并计算实际值与期望值之间的差异。不太了解的同学可以参考这篇博客卡方分箱中卡方值的计算。
然后,给出原文中关于卡方分箱方法中卡方值计算的介绍,内容如下(左边为原文,右边是从某篇中文文献中截取出来的中文解释)。对于卡方分箱中卡方值的计算,这里有个需要注意的地方:重点观察下标 i i i, j j j的取值变化。
假设我们的待分箱矩阵A如下图右边所示,为了方便表示,矩阵中的数值用字母a,b,…表示。 R i R_i Ri和 C j C_j Cj分别是第 i i i行数据的和以及第 j j j列数据的和,其中, j ∈ [ 0 , k ] j \in[0, k] j∈[0,k], k k k是类别数(这里y只有两个取值,所以 k = 2 k=2 k=2), i ∈ [ 0 , z ] i \in[0, z] i∈[0,z], z z z是样本数。
卡方分箱中的卡方值是通过计算相邻两项的卡方值得到的。因此,分别计算 ( x 0 , x 1 ) (x_0,x_1) (x0,x1), ( x 1 , x 2 ) (x_1, x_2) (x1,x2), ( x 2 , x 3 ) (x_2,x_3) (x2,x3)的卡方值, x 0 x_0 x0和 x 1 x_1 x1的卡方值参见上面的介绍。这也正好解释了论文给出的公式中, i i i的取值只有两个(每次计算只考虑相邻的两项)。由于对于多分类问题,y的取值不止两个,因此,公式里面j的取值为 [ 1 , k ] [1,k] [1,k]。
在卡方分箱中卡方值计算的图里面,有用红色框框标出来的一句话,这里面有关键的一点解释错了(红色框框标出)。中文解释说, C j / N C_j / N Cj/N是 j j j类样本在总体中占的比例。但根据我们前面的介绍,对于 x 0 x_0 x0和 x 1 x_1 x1的卡方值, C j / N C_j / N Cj/N真正表示的是 j j j类样本在 x 0 x_0 x0和 x 1 x_1 x1这两项中占的比例。。
至此,可以给出卡方值计算的代码:
def calc_chi2(count, group1, group2):
"""
根据分组信息(group)计算各分组的卡方值
:param count: DataFrame 待分箱变量各取值的正负样本数
:param group1: list 单个分组信息
:param group2: list 单个分组信息
:return: 该分组的卡方值
"""
count_intv1 = count.loc[count.index.isin(group1)].sum(axis=0).values
count_intv2 = count.loc[count.index.isin(group2)].sum(axis=0).values
count_intv = np.vstack((count_intv1, count_intv2))
# 计算四联表
row_sum = count_intv.sum(axis=1)
col_sum = count_intv.sum(axis=0)
total_sum = count_intv.sum()
# 计算期望样本数
count_exp = np.ones(count_intv.shape) * col_sum / total_sum
count_exp = (count_exp.T * row_sum).T
# 计算卡方值
chi2 = (count_intv - count_exp) ** 2 / count_exp
chi2[count_exp == 0] = 0
return chi2.sum()
chi2_list = [calc_chi2(count, group[idx], group[idx + 1]) for idx in range(len(group) - 1)]
代码实现的时候有一个小trick。对于每个四联表,可以选择用for循环,循环四次,依次计算出每个值对应的期望值 E i j E_{ij} Eij。但通过简单的矩阵变换,可以将转换为矩阵运算。由卡方值的计算公式可知:
E i j = [ ( a + b ) ( a + c ) n ( a + b ) ( b + d ) n ( a + c ) ( b + d ) n ( b + d ) ( c + d ) n ] = [ a + b c + d ] ∗ [ a + c n b + d n ] E_{ij} = { \left[\begin{array}{ccc} \frac{(a+b)(a+c)}{n} & \frac{(a+b)(b+d)}{n} \\ \frac{(a+c)(b+d)}{n} & \frac{(b+d)(c+d)}{n} \end{array} \right]}={ \left[\begin{array}{ccc} a+b \\ c+d \end{array} \right]} * { \left[\begin{array}{ccc} \frac{a+c}{n} & \frac{b+d}{n} \end{array} \right]} Eij=[n(a+b)(a+c)n(a+c)(b+d)n(a+b)(b+d)n(b+d)(c+d)]=[a+bc+d]∗[na+cnb+d]
即:
E i j = r o w _ s u m . T ∗ c o l _ s u m n = ( c o l _ s u m n . T ∗ r o w _ s u m ) . T E_{ij}=row\_sum.T * \frac{col\_sum}{n}= (\frac{col\_sum}{n}.T * row\_sum).T Eij=row_sum.T∗ncol_sum=(ncol_sum.T∗row_sum).T
当你去网上搜索相关的内容时,你会看到很多类似如下的解释和代码。这些代码就是按照中文解释中的思路去计算卡方值,乍一看还挺有道理的。这里很感谢这篇博客Python评分卡建模—卡方分箱,在我犹豫不决的时候,是它让我坚持了自己的想法。
这一部分其实没有太多难点,思想很简单,计算得到相邻分组的卡方值后,找到卡方值最小的分组并合。先直接给出初始版本的代码:
def merge_adjacent_intervals(chi2_list, group):
"""
根据卡方值合并卡方值最小的相邻分组
:param chi2_list: list 每个分组的卡方值
:param group: list 分组信息
:return: 合并后的分组信息及卡方值
"""
min_idx = chi2_list.index(min(chi2_list))
# 根据卡方值合并卡方值最小的相邻分组
group[min_idx] = group[min_idx] + group[min_idx+1]
group.remove(group[min_idx+1])
return group
对应的chiMerge代码如下:
def Chi_Merge(count, max_interval=6, sig_level=0.05):
"""
基于ChiMerge的卡方离散化方法
:param count: DataFrame 待分箱变量各取值的正负样本数
:param max_interval: int 最大分箱数量
:param sig_level: 显著性水平(significance level) = 1 - 置信度
:return: 分组信息(group)
"""
print("ChiMerge分箱开始:")
deg_freedom = len(count.columns) - 1 # 自由度(degree of freedom)= y类别数-1
chi2_threshold = chi2.ppf(1 - sig_level, deg_freedom) # 卡方阈值
group = np.array(count.index).reshape(-1, 1).tolist() # 分组信息
while len(group) > max_interval:
# 2. 计算相邻分组的卡方值
chi2_list = [calc_chi2(count, group[idx], group[idx + 1]) for idx in range(len(group) - 1)]
print(chi2_list)
# 3. 合并相似分组
if min(chi2_list) >= chi2_threshold:
print("最小卡方值%.3f大于卡方阈值%.3f,分箱合并结束!!!" % (min(chi2_list), chi2_threshold))
break
group = merge_adjacent_intervals(chi2_list, group)
print("ChiMerge分箱完成!!!")
return group
这里同样可以采用一个小trick,即每次合并区间后,不重新计算整个列表的卡方值,而是动态更新卡方值的数组(chi2_list)。代码如下(大家自行体会,很简单的):
def merge_adjacent_intervals(count, chi2_list, group):
"""
根据卡方值合并卡方值最小的相邻分组并更新卡方值
:param count: DataFrame 待分箱变量的
:param chi2_list: list 每个分组的卡方值
:param group: list 分组信息
:return: 合并后的分组信息及卡方值
"""
min_idx = chi2_list.index(min(chi2_list))
# 根据卡方值合并卡方值最小的相邻分组
group[min_idx] = group[min_idx] + group[min_idx+1]
group.remove(group[min_idx+1])
# 更新卡方值
if min_idx == 0:
chi2_list.pop(min_idx)
chi2_list[min_idx] = calc_chi2(count, group[min_idx], group[min_idx+1])
elif min_idx == len(group)-1:
chi2_list[min_idx-1] = calc_chi2(count, group[min_idx-1], group[min_idx])
chi2_list.pop(min_idx)
else:
chi2_list[min_idx-1] = calc_chi2(count, group[min_idx-1], group[min_idx])
chi2_list.pop(min_idx)
chi2_list[min_idx] = calc_chi2(count, group[min_idx], group[min_idx+1])
return chi2_list, group
def Chi_Merge1(count, max_interval=6, sig_level=0.05):
"""
基于ChiMerge的卡方离散化方法
:param count: DataFrame 待分箱变量各取值的正负样本数
:param max_interval: int 最大分箱数量
:param sig_level: 显著性水平(significance level) = 1 - 置信度
:return: 分组信息(group)
"""
print("ChiMerge分箱开始:")
deg_freedom = len(count.columns) - 1 # 自由度(degree of freedom)= y类别数-1
chi2_threshold = chi2.ppf(1 - sig_level, deg_freedom) # 卡方阈值
group = np.array(count.index).reshape(-1, 1).tolist() # 分组信息
# 2. 计算相邻分组的卡方值
chi2_list = [calc_chi2(count, group[idx], group[idx + 1]) for idx in range(len(group) - 1)]
# 3. 合并相似分组并更新卡方值
while 1:
if min(chi2_list) >= chi2_threshold:
print("最小卡方值%.3f大于卡方阈值%.3f,分箱合并结束!!!" % (min(chi2_list), chi2_threshold))
break
if len(group) <= max_interval:
print("分组长度%s等于指定分组数%s" % (len(group), max_interval))
break
chi2_list, group = merge_adjacent_intervals(count, chi2_list, group)
# print(chi2_list)
print("ChiMerge分箱完成!!!")
return group
卡方分箱的停止条件有如下两种选择:
(1)分箱个数等于指定的分箱数目(max_interval):限制最终的分箱个数结果,每次将样本中具有最小卡方值的 区间与相邻的最小卡方区间进行合并,直到分箱个数达到限制条件为止。
(2)最小卡方值大于卡方阈值(chi2_threshold):根据自由度和显著性水平得到对应的卡方阈值,如果分箱的各区间最小卡方值小于卡方阈值,则继续合并,直到最小卡方值超过设定阈值为止。
可以两个同时用,也可以只用一个。看实际需求调整即可。
阈值的意义
类别和属性独立时,有90%的可能性,计算得到的卡方值会小于4.6。 大于阈值4.6的卡方值就说明属性和类不是相互独立的,不能合并。如果阈值选的大,区间合并就会进行很多次,离散后的区间数量少、区间大。
需要补充说明的是,
分箱完成后,要对分箱结果进行评价。评分卡模型中最常用的是WOE和IV值,先直接给出代码,后面专门讨论。要注意的一点是,woe和iv值只能针对二分类问题计算。
def calc_IV(count):
"""
计算各分组的WOE值以及IV值
:param count: DataFrame 排好序的各组中正负样本分布
:return: 各分箱的woe和iv值
计算公式:WOE_i = ln{(sum_i / sum_T) / [(size_i - sum_i) / (size_T - sum_T)]}
计算公式:IV_i = [sum_i / sum_T - (size_i - sum_i) / (size_T - sum_T)] * WOE_i
"""
# 计算全体样本中好坏样本的比重
good = (count[1] / count[1].sum()).values
bad = (count[0] / count[0].sum()).values
woe = np.log(good / bad)
if 0 in bad:
ind = np.where(bad == 0)[0][0]
woe[ind] = 0
print('第%s类负例样本个数为0!!!' % ind)
if 0 in good:
ind = np.where(good == 0)[0][0]
woe[ind] = 0
print('第%s类正例样本个数为0!!!' % ind)
iv = (good - bad) * woe
return woe, iv
https://github.com/Lucky-Bone/Discretization