之前介绍了分箱的理论:
本次针对卡方分箱的代码进行解释
数据集及完整代码:https://github.com/Andyszl/Feature_Engineering/blob/master/卡方分箱.ipynb
分箱
分箱的定义
- 将连续变量离散化
- 将多状态的离散变量合并成少状态
- 相近合并
分箱的重要性
- 稳定性:避免特征中无意义的波动对评分带来的波动–变量的细微变动引起评分的波动是无意义的
- 健壮性:避免了极端值的影响
分箱的优势
- 可以将缺失值作为独立的一个箱代入模型中
- 将所有的变量变换到相似的尺度上–无量纲话即标准化
分箱的限制-缺点
- 计算量大
- 分箱后需要编码-信息丢失
KS分箱
- 原理:让分箱后组别的分布的差异最大化
KS的计算方式:
- 计算每个评分区间的好坏账户数。
- 计算各每个评分区间的累计好账户数占总好账户数比率(good%)和累计坏账户数占总坏账户数比率(bad%)。
- 计算每个评分区间累计坏账户比与累计好账户占比差的绝对值(累计good%-累计bad%),然后对这些绝对值取最大值记得到KS值。
- 对于连续性变量
- 1、 排序,
- 2、 计算每一点的KS值
- 3、 选取最大的KS对应的特征值,将X分为与两部分
- 4、 对于分好的两部分重复2、3,直到满足终止条件
- 终止条件
- 下一步分箱后,最小箱的占比低于设定的阈值(通常为0.05)
- 下一步分箱后,该箱对应的标签类别全部为1或者0
- 下一步分箱后,bad rate不单调
- 离散程度比较高的变量
1、编码
2、依据连续变量的方式进行分箱
- 无序变量可以根据bad rate排序进行编码,比如职业
- 有序变量不可以根据bad rate排序,如学历,需要按照自身排序再计算KS值
卡方分箱
卡方分箱是依赖于卡方检验的分箱方法,在统计指标上选择卡方统计量(chi-Square)进行判别,分箱的基本思想是判断相邻的两个区间是否有分布差异,基于卡方统计量的结果进行自下而上的合并,直到满足分箱的限制条件为止。
KS只能二分类,卡方可以多分类
- 设定卡方阈值
- 初始化,根据离散的属性对实例进行排序,每个实例属于一个区间
- 合并区间
- 计算每一对相邻区间的卡方值
- 将卡方值最小的一对区间合并
第i区间第j类的实例的数量
:的期望频率,=,是第i组的样本数,是第j类样本再全体中的比例
卡方统计量衡量了区间内样本的频数分布与整体样本的频数分布的差异性,在做分箱处理时可以使用两种限制条件:
(1)分箱个数:限制最终的分箱个数结果,每次将样本中具有最小卡方值的区间与相邻的最小卡方区间进行合并,直到分箱个数达到限制条件为止。
(2)卡方阈值:根据自由度和显著性水平得到对应的卡方阈值,如果分箱的各区间最小卡方值小于卡方阈值,则继续合并,直到最小卡方值超过设定阈值为止。
再补充两点,
1、由于卡方分箱是思想是相邻区间合并,在初始化时对变量属性需先进行排序,要注意名义变量的排序顺序
2、卡方阈值的自由度为 分箱数-1,显著性水平可以取10%,5%或1%
注意
- 使用卡方分箱默认不超过5个箱
- 分享后需要bad rate具有单调性。如果不满足需要相邻进行合并,直到单调
- 分箱必须覆盖所有训练样本外可能存在的值
- 当该变量可以完全区分目标变量时,需要认真价差改变量的合理性
- 原始数据很多时,为了减少时间开销,通常选取较少的初始切分点(例如50), 注意数据分布不均匀。比如等距分布,可能大部分数据只分布在几个箱里,而大部分箱里面几乎没有数据,所以不建议等距分箱
- 对于类别变量,当类别很少时,原则上不需要分箱,比如婚姻状况;其次,当个别或者几个类别的bad rate为0时,需要很最小的非0的bad rate的箱进行合并有可能时事后变量
等距分箱
data['income_cut']=pd.cut(data['annual_inc'],8)
data['income_cut'].value_counts()
(-1996.0, 753500.0] 39755
(753500.0, 1503000.0] 25
(1503000.0, 2252500.0] 3
(5250500.0, 6000000.0] 1
(3751500.0, 4501000.0] 1
(4501000.0, 5250500.0] 0
(3002000.0, 3751500.0] 0
(2252500.0, 3002000.0] 0
Name: income_cut, dtype: int64
等频分箱
data['income_cut4']=pd.qcut(data['annual_inc'],8,duplicates='drop')
#duplicates='drop'如果有重复值自动剔除
data['income_cut2'].value_counts()
(40500.0, 50000.0] 5810
(82350.0, 107000.0] 4995
(31400.0, 40500.0] 4984
(3999.999, 31400.0] 4975
(69500.0, 82350.0] 4964
(59000.0, 69500.0] 4957
(107000.0, 6000000.0] 4951
(50000.0, 59000.0] 4149
Name: income_cut2, dtype: int64
编码
独热编码
- 维度灾难
WOE编码
WOE的全称是“Weight of Evidence”,即证据权重。WOE是对原始自变量的一种编码形式。
有监督的编码方式,将预测类别的集中度的属性作为编码的数值
优势:
- 将特征的值规范到相近的尺度上(WOE的绝对是波动范围在0.1~3中)
- 具有业务含义
缺点
- 每个箱中同时包含好、坏两个类别
计算公式
- 好样本和坏样本的数量需要大于0,如果B等于0,那么公式没有意义;G等于0,log没有意义
- WOE的取值可能是正的、负的,也可以是0
- 分子分母可以互换同一个模型里面的分子分母要保持一致
代码部分
卡方分箱
- 拆分数据
def SplitData(df, col, numOfSplit, special_attribute=[]):
'''
:param df: 按照col排序后的数据集
:param col: 待分箱的变量
:param numOfSplit: 切分的组别数
:param special_attribute: 在切分数据集的时候,某些特殊值需要排除在外
:return: 在原数据集上增加一列,把原始细粒度的col重新划分成粗粒度的值,便于分箱中的合并处理
'''
df2 = df.copy()
if special_attribute != []:
df2 = df.loc[~df[col].isin(special_attribute)]
N = df2.shape[0]#总样本数
n = round(N/numOfSplit)#每一层有多少样本
print(n)
splitPointIndex = [i*n for i in range(1,numOfSplit)]
#print(splitPointIndex)
rawValues = sorted(list(df2[col]))#将所有的样本进行排序
splitPoint = [rawValues[i] for i in splitPointIndex]#以样本排序的rank为索引,找到每个临界值的点的数值
splitPoint = sorted(list(set(splitPoint)))
return splitPoint
临界值的选取1
def AssignGroup(x, bin):
N = len(bin)
#如果值小于最小的分箱值,则取最小的分箱值
if x<=min(bin):
return min(bin)
# 如果值大于最大的分箱值,则取10e10
elif x>max(bin):
return 10e10
else:
#介于中间的值取又边界值
for i in range(N-1):
if bin[i] < x <= bin[i+1]:
return bin[i+1]
临界值的选取2
def AssignBin(x, cutOffPoints,special_attribute=[]):
'''
:param x: the value of variable
:param cutOffPoints: the ChiMerge result for continous variable
:param special_attribute: the special attribute which should be assigned separately
:return: bin number, indexing from 0
for example, if cutOffPoints = [10,20,30], if x = 7, return Bin 0. If x = 35, return Bin 3
'''
numBin = len(cutOffPoints) + 1 + len(special_attribute)
if x in special_attribute:
i = special_attribute.index(x)+1
return 'Bin {}'.format(0-i)
if x<=cutOffPoints[0]:
return 'Bin 0'
elif x > cutOffPoints[-1]:
return 'Bin {}'.format(numBin-1)
else:
for i in range(0,numBin-1):
if cutOffPoints[i] < x <= cutOffPoints[i+1]:
return 'Bin {}'.format(i+1)
- 计算坏样本率
def BinBadRate(df, col, target, grantRateIndicator=0):
'''
:param df: 需要计算好坏比率的数据集
:param col: 需要计算好坏比率的特征
:param target: 好坏标签
:param grantRateIndicator: 1返回总体的坏样本率,0不返回
:return: 每箱的坏样本率,以及总体的坏样本率(当grantRateIndicator==1时)
'''
#先计算每个值的出现次数
total = df.groupby([col])[target].count()
#print("1",total)
total = pd.DataFrame({'total': total})
#先计算每个值中1[即坏样本]的出现次数
bad = df.groupby([col])[target].sum()
bad = pd.DataFrame({'bad': bad})
#将每个值的总数和bad样本数合并
regroup = total.merge(bad, left_index=True, right_index=True, how='left')
regroup.reset_index(level=0, inplace=True)
#计算每个值的坏样本率
regroup['bad_rate'] = regroup.apply(lambda x: x.bad * 1.0 / x.total, axis=1)
dicts = dict(zip(regroup[col],regroup['bad_rate']))
if grantRateIndicator==0:
return (dicts, regroup)
N = sum(regroup['total'])
B = sum(regroup['bad'])
overallRate = B * 1.0 / N
return (dicts, regroup, overallRate)
- 计算卡方值
def Chi2(df, total_col, bad_col, overallRate):
'''
:param df: 包含全部样本总计与坏样本总计的数据框
:param total_col: 全部样本的个数
:param bad_col: 坏样本的个数
:param overallRate: 全体样本的坏样本占比
:return: 卡方值
'''
df2 = df.copy()
# 期望坏样本个数=全部样本个数*平均坏样本占比,即计算Eij
df2['expected'] = df[total_col].apply(lambda x: x*overallRate)
combined = zip(df2['expected'], df2[bad_col])
chi = [(i[0]-i[1])**2/i[0] for i in combined]
chi2 = sum(chi)
return chi2
- 计算分箱结果
### ChiMerge_MaxInterval: split the continuous variable using Chi-square value by specifying the max number of intervals
def ChiMerge1(df, col, target, max_interval=5,special_attribute=[],minBinPcnt=0):
'''
:param df: 包含目标变量与分箱属性的数据框
:param col: 需要分箱的属性
:param target: 目标变量,取值0或1
:param max_interval: 最大分箱数。如果原始属性的取值个数低于该参数,不执行这段函数
:param special_attribute: 不参与分箱的属性取值
:param minBinPcnt:最小箱的占比,默认为0
:return: 分箱结果
'''
colLevels = sorted(list(set(df[col])))
N_distinct = len(colLevels)
if N_distinct <= max_interval: #如果原始属性的取值个数低于max_interval,不执行这段函数
print ("The number of original levels for {} is less than or equal to max intervals".format(col))
return colLevels[:-1]
else:
if len(special_attribute)>=1:
df1 = df.loc[df[col].isin(special_attribute)]
df2 = df.loc[~df[col].isin(special_attribute)]
else:
df2 = df.copy()
N_distinct = len(list(set(df2[col])))
# 步骤一: 通过col对数据集进行分组,求出每组的总样本数与坏样本数
if N_distinct > 100:
split_x = SplitData(df2, col, 100)
df2['temp'] = df2[col].map(lambda x: AssignGroup(x, split_x))
else:
df2['temp'] = df[col]
# 总体bad rate将被用来计算expected bad count
(binBadRate, regroup, overallRate) = BinBadRate(df2, 'temp', target, grantRateIndicator=1)
# 首先,每个单独的属性值将被分为单独的一组
# 对属性值进行排序,然后两两组别进行合并
colLevels = sorted(list(set(df2['temp'])))
groupIntervals = [[i] for i in colLevels]
# 步骤二:建立循环,不断合并最优的相邻两个组别,直到:
# 1,最终分裂出来的分箱数<=预设的最大分箱数
# 2,每箱的占比不低于预设值(可选)
# 3,每箱同时包含好坏样本
# 如果有特殊属性,那么最终分裂出来的分箱数=预设的最大分箱数-特殊属性的个数
split_intervals = max_interval - len(special_attribute)
while (len(groupIntervals) > split_intervals): # 终止条件: 当前分箱数=预设的分箱数
# 每次循环时, 计算合并相邻组别后的卡方值。具有最小卡方值的合并方案,是最优方案
chisqList = []
for k in range(len(groupIntervals)-1):
temp_group = groupIntervals[k] + groupIntervals[k+1]
df2b = regroup.loc[regroup['temp'].isin(temp_group)]
chisq = Chi2(df2b, 'total', 'bad', overallRate)
chisqList.append(chisq)
best_comnbined = chisqList.index(min(chisqList))
groupIntervals[best_comnbined] = groupIntervals[best_comnbined] + groupIntervals[best_comnbined+1]
# after combining two intervals, we need to remove one of them
groupIntervals.remove(groupIntervals[best_comnbined])
groupIntervals = [sorted(i) for i in groupIntervals]
print(groupIntervals)
cutOffPoints = [max(i) for i in groupIntervals[:-1]]
return cutOffPoints
分箱完成后需要对分箱进行检查
- 检查是否有箱没有好或者坏样本。如果有,需要跟相邻的箱进行合并,直到每箱同时包含好坏样本
groupedvalues = df2['temp'].apply(lambda x: AssignBin(x, cutOffPoints))
df2['temp_Bin'] = groupedvalues
(binBadRate,regroup) = BinBadRate(df2, 'temp_Bin', target)
[minBadRate, maxBadRate] = [min(binBadRate.values()),max(binBadRate.values())]
while minBadRate ==0 or maxBadRate == 1:
# 找出全部为好/坏样本的箱
indexForBad01 = regroup[regroup['bad_rate'].isin([0,1])].temp_Bin.tolist()
bin=indexForBad01[0]
# 如果是最后一箱,则需要和上一个箱进行合并,也就意味着分裂点cutOffPoints中的最后一个需要移除
if bin == max(regroup.temp_Bin):
cutOffPoints = cutOffPoints[:-1]
# 如果是第一箱,则需要和下一个箱进行合并,也就意味着分裂点cutOffPoints中的第一个需要移除
elif bin == min(regroup.temp_Bin):
cutOffPoints = cutOffPoints[1:]
# 如果是中间的某一箱,则需要和前后中的一个箱进行合并,依据是较小的卡方值
else:
# 和前一箱进行合并,并且计算卡方值
currentIndex = list(regroup.temp_Bin).index(bin)
prevIndex = list(regroup.temp_Bin)[currentIndex - 1]
df3 = df2.loc[df2['temp_Bin'].isin([prevIndex, bin])]
(binBadRate, df2b) = BinBadRate(df3, 'temp_Bin', target)
chisq1 = Chi2(df2b, 'total', 'bad', overallRate)
# 和后一箱进行合并,并且计算卡方值
laterIndex = list(regroup.temp_Bin)[currentIndex + 1]
df3b = df2.loc[df2['temp_Bin'].isin([laterIndex, bin])]
(binBadRate, df2b) = BinBadRate(df3b, 'temp_Bin', target)
chisq2 = Chi2(df2b, 'total', 'bad', overallRate)
if chisq1 < chisq2:
cutOffPoints.remove(cutOffPoints[currentIndex - 1])
else:
cutOffPoints.remove(cutOffPoints[currentIndex])
# 完成合并之后,需要再次计算新的分箱准则下,每箱是否同时包含好坏样本
groupedvalues = df2['temp'].apply(lambda x: AssignBin(x, cutOffPoints))
df2['temp_Bin'] = groupedvalues
(binBadRate, regroup) = BinBadRate(df2, 'temp_Bin', target)
[minBadRate, maxBadRate] = [min(binBadRate.values()), max(binBadRate.values())]
- 需要检查分箱后的最小占比
if minBinPcnt > 0:
groupedvalues = df2['temp'].apply(lambda x: AssignBin(x, cutOffPoints))
df2['temp_Bin'] = groupedvalues
valueCounts = groupedvalues.value_counts().to_frame()
valueCounts['pcnt'] = valueCounts['temp'].apply(lambda x: x * 1.0 / N)
valueCounts = valueCounts.sort_index()
minPcnt = min(valueCounts['pcnt'])
while minPcnt < 0.05 and len(cutOffPoints) > 2:
# 找出占比最小的箱
indexForMinPcnt = valueCounts[valueCounts['pcnt'] == minPcnt].index.tolist()[0]
# 如果占比最小的箱是最后一箱,则需要和上一个箱进行合并,也就意味着分裂点cutOffPoints中的最后一个需要移除
if indexForMinPcnt == max(valueCounts.index):
cutOffPoints = cutOffPoints[:-1]
# 如果占比最小的箱是第一箱,则需要和下一个箱进行合并,也就意味着分裂点cutOffPoints中的第一个需要移除
elif indexForMinPcnt == min(valueCounts.index):
cutOffPoints = cutOffPoints[1:]
# 如果占比最小的箱是中间的某一箱,则需要和前后中的一个箱进行合并,依据是较小的卡方值
else:
# 和前一箱进行合并,并且计算卡方值
currentIndex = list(valueCounts.index).index(indexForMinPcnt)
prevIndex = list(valueCounts.index)[currentIndex - 1]
df3 = df2.loc[df2['temp_Bin'].isin([prevIndex, indexForMinPcnt])]
(binBadRate, df2b) = BinBadRate(df3, 'temp_Bin', target)
chisq1 = Chi2(df2b, 'total', 'bad', overallRate)
# 和后一箱进行合并,并且计算卡方值
laterIndex = list(valueCounts.index)[currentIndex + 1]
df3b = df2.loc[df2['temp_Bin'].isin([laterIndex, indexForMinPcnt])]
(binBadRate, df2b) = BinBadRate(df3b, 'temp_Bin', target)
chisq2 = Chi2(df2b, 'total', 'bad', overallRate)
if chisq1 < chisq2:
cutOffPoints.remove(cutOffPoints[currentIndex - 1])
else:
cutOffPoints.remove(cutOffPoints[currentIndex])
annual_inc is in processing
398
[[14400.0], [35142.0], [36000.0], [39192.0], [100000000000.0]]
regroup: annual_inc_Bin total bad bad_rate
0 Bin 0 423 104 0.245863
1 Bin 1 6344 1097 0.172919
2 Bin 2 736 149 0.202446
3 Bin 3 1254 215 0.171451
4 Bin 4 31028 4105 0.132300
combined: <zip object at 0x11718c8c8>
badRate: [0.2458628841607565, 0.17291929382093316, 0.20244565217391305, 0.17145135566188197, 0.13229985819260023]
badRateMonotone: [False, True, False, False]
398
[[14400.0], [35142.0], [36000.0], [100000000000.0]]
regroup: annual_inc_Bin total bad bad_rate
0 Bin 0 423 104 0.245863
1 Bin 1 6344 1097 0.172919
2 Bin 2 736 149 0.202446
3 Bin 3 32282 4320 0.133821
combined: <zip object at 0x1171d4548>
badRate: [0.2458628841607565, 0.17291929382093316, 0.20244565217391305, 0.13382070503686264]
badRateMonotone: [False, True, False]
398
[[14400.0], [36000.0], [100000000000.0]]
regroup: annual_inc_Bin total bad bad_rate
0 Bin 0 423 104 0.245863
1 Bin 1 7080 1246 0.175989
2 Bin 2 32282 4320 0.133821
combined: <zip object at 0x117111b48>
badRate: [0.2458628841607565, 0.17598870056497176, 0.13382070503686264]
badRateMonotone: [False, False]
计算WOE和IV
def CalcWOE(df, col, target):
'''
:param df: dataframe containing feature and target
:param col: the feature that needs to be calculated the WOE and iv, usually categorical type
:param target: good/bad indicator
:return: WOE and IV in a dictionary
'''
total = df.groupby([col])[target].count()
total = pd.DataFrame({'total': total})
bad = df.groupby([col])[target].sum()
bad = pd.DataFrame({'bad': bad})
regroup = total.merge(bad, left_index=True, right_index=True, how='left')
regroup.reset_index(level=0, inplace=True)
N = sum(regroup['total'])
B = sum(regroup['bad'])
regroup['good'] = regroup['total'] - regroup['bad']
G = N - B
regroup['bad_pcnt'] = regroup['bad'].map(lambda x: x*1.0/B)
regroup['good_pcnt'] = regroup['good'].map(lambda x: x * 1.0 / G)
regroup['WOE'] = regroup.apply(lambda x: np.log(x.good_pcnt*1.0/x.bad_pcnt),axis = 1)
WOE_dict = regroup[[col,'WOE']].set_index(col).to_dict(orient='index')
for k, v in WOE_dict.items():
WOE_dict[k] = v['WOE']
IV = regroup.apply(lambda x: (x.good_pcnt-x.bad_pcnt)*np.log(x.good_pcnt*1.0/x.bad_pcnt),axis = 1)
IV = sum(IV)
return {"WOE": WOE_dict, 'IV':IV}
{‘IV’: 0.022499080659967422,
‘WOE’: {14400.0: -0.6737478488842542,
36000.0: -0.25078360144745787,
100000000000.0: 0.0730429907818877}}