最近看了很多评分卡文章,很多前辈都讲解了程序,然后讲解了原理,但个人感觉程序部分讲的不够细致,很多时候看觉得难懂,所以,便产生了想要很细致地解释代码的想法,通过手写和思路讲解,加深自己的理解~
再看这篇文章前,最好先了解卡方分箱算法的原理再来看代码,这样才能有帮助,并且看的时候要从头到尾的看完,不要看一半,有些函数是嵌在其他函数中计算的,明白这点很重要~
这里主要包括了一些基础函数和主体函数:
SplitData函数——用于对某变量进行划分,根据划分的组数,返回划分点数值组成的列表
Chi2函数——用于计算卡方值,返回卡方值(按照卡方检验的原理进行计算)
BinBadRate函数——按某变量进行分组,计算分组后每组的坏样本率,返回的有,字典形式,数据框,总体坏样本率(可选)
AssignGroup函数——根据分组后的划分点列表,给某个需分箱的变量的每个取值进行分箱前的匹配,形成对应箱的映射
AssignBin函数——将某列的每个取值进行分箱编号
ChiMerge函数——卡方分箱操作的主体函数,其中调用了前面五个基础函数,返回的是最终的满足所有限制条件的划分点列表
其中,需要满足:
(1)最后分裂出的分箱数 <= 预设的最大分箱数
(2)每个箱体必须同时包含好坏样本
(3)每个箱体的占比不低于预设值(可选)
(4)如果有特殊的属性值,则最终的分箱数 = 预设的最大分箱数 - 特殊值个数
UnsupervisedSplitBin函数——对数值型变量进行分组,分组的依据有等频和等距,最后返回划分点列表
BadRateEncoding函数——用于对类别型变量进行BadRate编码,返回该列每个取值对应的坏样本率
CalcWOE函数——对某个变量的分箱结果进行WOE计算和IV值计算,最后返回该变量每个箱体对应的WOE值以及变量的IV值
def SpiltData(df,col,numOfSplit,special_attribute=[]):
'''
goal:该函数用于获得数据切分时对应位置的数值
df:数据集
col:对数据集中的某列进行操作
numOfSplit:划分的组数
special_attribute:用于过滤掉一些特殊的值,不参与数据划分
return:返回划分点位置对应的数值组成的列表
'''
df2 = df.copy()
if special_attribute != []:
df2 = df2.loc[~df2[col].isin(special_attribute)] # 排除有特殊值的样本行
N = len(df2) # 样本总数
n = int(N/numOfSplit) # 每组包含的样本量
SplitPointIndex = [i*n for i in range(1,numOfSplit) ] # 不包含numOfSplit
# 解释:如总样本N为20,numofsplit为2组,则每组包含10个元素(n),则最后得到的SplitPointIndex=[10],也即切分点位置为10,包括了0-9位置十个数
rawValues = sorted( list(df[col]) ) # sorted返回一个新的排列后的列表
SplitPoint = [rawValues[i] for i in SplitPointIndex] # 返回位置索引上对应的数值
# 为了以防万一,去重再排序
SplitPoint = sorted(list(set(SplitPoint)))
return SplitPoint
def Chi2(df,total_col,bad_col):
'''
goal:获得卡方值
df;数据框,包含了每个分组下的样本总数和坏样本数
total_col:列名,元素由各个分组下的样本总数构成
bad_col:列名,元素由各个分组下的坏样本数构成
return:返回卡方值
'''
df2 = df.copy()
# 求出总体的坏样本率和好样本率
badRate = sum(df2[bad_col]) * 1.0 / sum(df2[total_col])
df2['good'] = df2.apply(lambda x: x[total_col] - x[bad_col], axis = 1 )
goodRate = sum(df2['good']) * 1.0 / sum(df2[total_col])
# 当全部样本只有好或者坏样本时,卡方值为0
if badRate in [0,1]:
return 0
# 然后,根据总体的好坏样本率算出期望的好坏样本数,计算公式为:
# 期望坏(好)样本个数 = 全部样本个数 * 总体的坏(好)样本率
df2['badExpected'] = df2[total_col].apply(lambda x: x*badRate)
df2['goodExpected'] = df[total_col].apply(lambda x: x * goodRate)
badCombined = zip(df2['badExpected'], df2[bad_col])
goodCombined = zip(df2['goodExpected'], df2['good'])
# 按照卡方计算的公式计算卡方值: (o - E)^2 / o, o代表实际值,E代表期望值
badChi = [(i[0]-i[1])**2/i[0] for i in badCombined]
goodChi = [(i[0] - i[1]) ** 2 / i[0] for i in goodCombined]
chi2 = sum(badChi) + sum(goodChi)
return chi2
'''
注:这个函数可以用来算两两相邻组的卡方值,这取决于df的行数(即形式)
如果数据框df由两个相邻的行组成,最后算出来的就是两两相邻组的卡方值
'''
def BinBadRate(df,col,target,grantRateIndicator=0):
'''
goal:该函数用于对变量按照取值(每个取值都是唯一的)进行分组,获得每箱的坏样本率,后期基于该值判断是否需要合并操作
f: 需要计算好坏比率的数据集
col: 需要计算好坏比率的特征
target: 好坏标签
grantRateIndicator: 1返回总体的坏样本率,0不返回
return: 每箱的坏样本率,以及总体的坏样本率(当grantRateIndicator==1时)
'''
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)
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)
else:
N = sum(regroup['total'])
B = sum(regroup['bad'])
overallRate = B * 1.0 / N
return (dicts, regroup, overallRate)
def AssignGroup( x , bin ):
'''
goal:该函数用于,根据分组后的划分点列表(bin),给某个需要分箱的变量进行分箱前的匹配,形成对应箱的映射,以便后期分箱操作,将值划分到不同箱体
x:某个变量的某个取值
bin:上述变量分组后(通过SplitData函数)对应的划分位置的数值组成的列表
return:x在分箱结果下的映射
'''
N = len(bin) # 划分点的长度
if x<=min(bin): # 如果某个取值小于等于最小划分点,则返回最小划分点
return min(bin)
elif x>max(bin): # 如果某个取值大于最大划分点,则返回10e10
return 10e10
else: # 除此之外,返回其他对应的划分点
for i in range(N-1):
if bin[i] < x <= bin[i+1]:
return bin[i+1]
def AssignBin( x , cutOffPoints , special_attribute=[] ):
'''
goal:该函数用于对某个列的某个取值进行分箱编号
:param x: 某个变量的某个取值
:param cutOffPoints: 上述变量的分组结果,用切分点表示,列表形式
:param special_attribute: 不参与分箱的特殊取值
:return: 分箱后的对应的第几个箱,从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 ChiMerge(df, col, target, max_interval=5,special_attribute=[],minBinPcnt=0):
'''
goal:该函数用于实际的卡方分箱算法操作,最后返回有实际的划分点组成的列表
df: 包含目标变量与分箱属性的数据框
col: 需要分箱的属性
target: 目标变量,取值0或1
max_interval: 最大分箱数。如果原始属性的取值个数低于该参数,不执行这段函数
special_attribute: 不参与分箱的属性取值
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() # 去掉special_attribute后的df
N_distinct = len(list(set(df2[col]))) # 去掉special_attribute后的非重复值计数
# 步骤一: 通过col对数据进行分组,求出每组的总样本数和坏样本数
if N_distinct > 100:
split_x = SplitData(df2,col,100) # 若非重复值计数超过100组,我们均将其转化成100组
df2['temp'] = df2[col].map(lambda x: AssignGroup(x,split_x))
# Assgingroup函数:每一行的数值和切分点做对比,返回原值在切分后的映射,
# 经过map以后,生成该特征的值对象的“分箱”后的值
else:
df2['temp'] = df2[col] # 不重复值计数不超过100时,不需要进行上述步骤
# 通过上述过程,我们现在可以将该列进行BadRate计算,用来计算每个箱体的坏样本率 以及总体的坏样本率
(binBadRate, regroup, overallRate) = BinBadRate(df2, 'temp', target, grantRateIndicator=1)
# 在此,我们将每个单独的属性值分成单独一组
# 对属性值进行去重排序,然后两两组别进行合并,用于后续的卡方值计算
colLevels = sorted(list(set(df2['temp'])))
groupIntervals = [ [i] for i in colLevels ] # 把每个箱的值打包成[[],[]]的形式
# 步骤二:通过循环的方式,不断的合并相邻的两个组别,直到:
# (1)最后分裂出的分箱数 <= 预设的最大分箱数
# (2)每个箱体必须同时包含好坏样本
# (3)每个箱体的占比不低于预设值(可选)
# (4)如果有特殊的属性值,则最终的分箱数 = 预设的最大分箱数 - 特殊值个数
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] # 返回的是,这两个值组成的列表
# 因此,可以通过temp_group,每次只选相邻的两组进行相关操作
df2b = regroup.loc[regroup['temp'].isin(temp_group)]
# 计算相邻两组的卡方值(通过调用Chi2函数)
chisq = Chi2(df2b,'total','bad')
chisqList.append(chisq)
best_comnbined = chisqList.index(min(chisqList)) # 检索最小卡方值所在的索引
# 把groupIntervals的值改成类似的值改成类似从[[1],[2],[3]]到[[1,2],[3]]
groupIntervals[best_comnbined] = groupIntervals[best_comnbined] + groupIntervals[best_comnbined+1]
groupIntervals.remove(groupIntervals[best_comnbined+1])
# 上述循环结束后,即可获得预设的分箱数,并且可以得到各分箱的划分点
groupIntervals = [sorted(i) for i in groupIntervals]
cutOffPoints = [max(i) for i in groupIntervals[:-1]]
# 然后,我们将某列进行分箱编号
groupedvalues = df2['temp'].apply(lambda x: AssignBin(x, cutOffPoints))
df2['temp_Bin'] = groupedvalues
# AssignBin函数:每一行的数值和切分点做对比,返回原值所在的分箱编号(形成新列)
# 进一步,我们想要保证每个箱体都有好坏样本数!
# 检查是否有箱没有好或者坏样本。如果有,需要跟相邻的箱进行合并,直到每箱同时包含好坏样本
(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')
# 和后一箱进行合并,并且计算卡方值
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')
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: # 如果函数调用初期给的minBinPct不是零,则进一步对箱体进行合并
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 / sum(valueCounts['temp']))
valueCounts = valueCounts.sort_index()
minPcnt = min(valueCounts['pcnt']) # 得到箱体的最小占比
# 如果箱体最小占比小于给定的分箱占比阈值 且 划分点大于2,进入合并循环中
while minPcnt < minBinPcnt 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')
# 和后一箱进行合并,并且计算卡方值
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')
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
valueCounts = groupedvalues.value_counts().to_frame()
valueCounts['pcnt'] = valueCounts['temp'].apply(lambda x: x * 1.0 / sum(valueCounts['temp']))
valueCounts = valueCounts.sort_index()
minPcnt = min(valueCounts['pcnt'])
cutOffPoints = special_attribute + cutOffPoints
return cutOffPoints
def UnsupervisedSplitBin(df,var,numOfSplit = 5, method = 'equal freq'):
'''
goal:该函数用于对数值型变量进行划分,最后返回划分点组成的列表
:param df: 数据集
:param var: 需要分箱的变量。仅限数值型变量!
:param numOfSplit: 需要分箱个数,默认是5
:param method: 分箱方法,'equal freq':,默认是等频,否则是等距
:return:返回划分点组成的列表
'''
if method == 'equal freq':
N = df.shape[0]
n = N / numOfSplit
splitPointIndex = [i * n for i in range(1, numOfSplit)]
rawValues = sorted(list(df[var]))
splitPoint = [rawValues[i] for i in splitPointIndex]
splitPoint = sorted(list(set(splitPoint)))
return splitPoint
else:
var_max, var_min = max(df[var]), min(df[var])
interval_len = (var_max - var_min)*1.0/numOfSplit
splitPoint = [var_min + i*interval_len for i in range(1,numOfSplit)]
return splitPoint
def BadRateEncoding(df, col, target):
'''
goal:对类别型变量继进行Badrate编码
:param df: 包含目标特征target和自变量col的数据框
:param col: 需要进行badrate编码的变量,通常是类别型变量!!
:param target: 目标特征,0-1变量
:return: 返回的是一个字典,第一个键对应的是col这列每个取值对应的坏样本率(有重复的),第二个键则是所有非重复值的坏样本率
'''
# 利用某个变量下的非重复值进行分箱,并获得每个箱体的坏样本率
regroup = BinBadRate(df, col, target, grantRateIndicator=0)[1]
# 然后每个箱体的箱体名和坏样本率列提取出来,形成以箱体名为索引的数据框形式,
# 进一步将其转换为字典形式——参考 https://blog.csdn.net/weixin_39791387/article/details/87627235
br_dict = regroup[[col,'bad_rate']].set_index([col]).to_dict(orient='index')
for k, v in br_dict.items(): # k为分箱名,v一个字典形式,'bad_rate'为字典键,v['bad_rate']为具体的箱对应的坏样本率
br_dict[k] = v['bad_rate']
badRateEnconding = df[col].map(lambda x: br_dict[x])
return {'encoding':badRateEnconding, 'bad_rate':br_dict}
def CalcWOE(df, col, target):
'''
goal:获得某个变量的分箱操作后每个箱体对应的WOE值,并且基于WOE值计算该变量的IV值!
:param df: 包含需要计算WOE的变量和目标变量
:param col: 需要计算WOE、IV的变量,必须是分箱后的变量,或者不需要分箱的类别型变量
:param target: 目标变量,0、1表示好、坏
:return: 返回WOE和IV
'''
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) # 每个箱体的好样本数占总体的好样本数的比例
# WOE计算公式: WOE(每个箱体) = log(该箱体的好样本数占总体的好样本数的比例/该箱体的好样本数占总体的好样本数的比例)
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(): # k代表箱体名,v代表了以WOE为键,箱体的实际WOE值为value的字典
WOE_dict[k] = v['WOE']
# 计算该变量的IV值:sum((某箱体的好样本数占总体的好样本数的比例- 该箱体的坏样本数占总体的坏样本数的比例)*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}