卡方分箱算法

最近看了很多评分卡文章,很多前辈都讲解了程序,然后讲解了原理,但个人感觉程序部分讲的不够细致,很多时候看觉得难懂,所以,便产生了想要很细致地解释代码的想法,通过手写和思路讲解,加深自己的理解~

再看这篇文章前,最好先了解卡方分箱算法的原理再来看代码,这样才能有帮助,并且看的时候要从头到尾的看完,不要看一半,有些函数是嵌在其他函数中计算的,明白这点很重要~

 

这里主要包括了一些基础函数和主体函数:

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}

 

  • 9
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值