之前介紹了分箱的理論:
https://blog.csdn.net/Andy_shenzl/article/details/88965169
https://blog.csdn.net/Andy_shenzl/article/details/89015772#3.1WOE
本次針對卡方分箱的代碼進行解釋
數據集及完整代碼: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}}