離散特徵在數據挖掘的過程中具有重要作用,因此特徵離散化是構建特徵工程的一個很常見、也很重要的環節。
卡方分箱作爲最經典的離散化方法之一,最近做項目需要用到時,卻發現這麼經典的功能python竟然沒有官方的封裝庫。找了許多資料,感覺講的都比較雜(一會chiMerge,一會chi2,一會單調性檢驗 O__O”… ),看的懷疑人生。最後實在不得已,只能翻出原論文 ChiMerge: Discretization of Numeric Attributes 出來拜讀一下。
看完論文後發現最原始的卡方分箱思想還是挺簡單的,只是網上很多資料講的層次不清晰,讓很多初學者看昏了頭。因此寫篇博客記錄復現時的一些想法和踩過的坑。
1. 分箱
首先,介紹一下什麼是分箱。分箱是將連續變量離散化,多狀態的離散變量合併成少狀態的過程。這句話裏包含兩個要點,第一點:分箱的對象可以是連續變量,也可以是離散變量。第二點:分箱的目的是將變量的可取值變少(更便於分析)。目前主流的分箱方法可以分爲兩大類:1)自底向上的基於合併(merge)機制的方法,如卡方分箱;2)自上向下的基於分割(split)機制的方法,如基於決策樹的分箱、bestKS分箱。後續,博主將一一開設博客介紹這些方法,敬請期待。
2. 卡方分箱 之 ChiMerge
前面說過,卡方分箱是典型的基於合併機制的自底向上離散化方法。其基於如下假設:如果兩個相鄰的區間具有非常類似的類分佈,則這兩個區間可以合併;否則,它們應當保持分開。此處衡量分佈相似性的指標就是卡方值。卡方值越低,類分佈的相似度越高。
因此,ChiMerge分箱的主要思想歸結爲一句話就是:將具有最小卡方值的相鄰區間合併在一起,直到滿足確定的停止準則。其通用流程如下:
這裏麪包含四個關鍵點:
1. 離散變量如何排序:大部分關於卡方分箱的介紹都是針對連續變量,對於離散變量,應該如何處理
2. 卡方值計算(坑最深的地方):如何計算相鄰兩項的卡方值
3. 區間合併:如何合併卡方值最小的兩項
4. 停止條件:何時結束循環,停止分箱
下面我們將具體討論這些問題。
2.1. 排序
卡方分箱的第一步即對數據排序。對於連續變量,直接根據變量數值大小排序即可。對於離散變量,由於取值不存在大小關係,無法直接排序。這裏一般採用的排序依據是:正例樣本的比例,即待分箱變量每個取值中正例樣本的比重,對應代碼中的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時,要求。當然,這裏可以根據個人需要調整pos_ratio的計算方式,以適應多分類問題。
2.2. 卡方值計算
大多數介紹卡方分箱的文章都沒有具體解釋相鄰區間的卡方值如何計算。在原論文中對於卡方值計算也比較簡略,這部分將着重討論這一內容。首先,給出卡方值的計算公式,如下圖所示(左邊:數據,右邊:對應的卡方值計算公式)。從公式來看,卡方值的計算其實並不複雜。對於四聯表中的每一項,分別計算每一項的期望值(分母部分),並計算實際值與期望值之間的差異。不太瞭解的同學可以參考這篇博客卡方分箱中卡方值的計算。
然後,給出原文中關於卡方分箱方法中卡方值計算的介紹,內容如下(左邊爲原文,右邊是從某篇中文文獻中截取出來的中文解釋)。對於卡方分箱中卡方值的計算,這裏有個需要注意的地方:重點觀察下標,的取值變化。
假設我們的待分箱矩陣A如下圖右邊所示,爲了方便表示,矩陣中的數值用字母a,b,…表示。和分別是第行數據的和以及第列數據的和,其中,, 是類別數(這裏y只有兩個取值,所以),,是樣本數。
卡方分箱中的卡方值是通過計算相鄰兩項的卡方值得到的。因此,分別計算,,的卡方值,和的卡方值參見上面的介紹。這也正好解釋了論文給出的公式中,的取值只有兩個(每次計算只考慮相鄰的兩項)。由於對於多分類問題,y的取值不止兩個,因此,公式裏面j的取值爲。
在卡方分箱中卡方值計算的圖裏面,有用紅色框框標出來的一句話,這裏面有關鍵的一點解釋錯了(紅色框框標出)。中文解釋說,是類樣本在總體中佔的比例。但根據我們前面的介紹,對於和的卡方值,真正表示的是類樣本在和這兩項中佔的比例。。
至此,可以給出卡方值計算的代碼:
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循環,循環四次,依次計算出每個值對應的期望值。但通過簡單的矩陣變換,可以將轉換爲矩陣運算。由卡方值的計算公式可知:
即:
當你去網上搜索相關的內容時,你會看到很多類似如下的解釋和代碼。這些代碼就是按照中文解釋中的思路去計算卡方值,乍一看還挺有道理的。這裏很感謝這篇博客Python評分卡建模—卡方分箱,在我猶豫不決的時候,是它讓我堅持了自己的想法。
2.3 區間合併
這一部分其實沒有太多難點,思想很簡單,計算得到相鄰分組的卡方值後,找到卡方值最小的分組併合。先直接給出初始版本的代碼:
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
2.4 停止條件
卡方分箱的停止條件有如下兩種選擇:
(1)分箱個數等於指定的分箱數目(max_interval):限制最終的分箱個數結果,每次將樣本中具有最小卡方值的 區間與相鄰的最小卡方區間進行合併,直到分箱個數達到限制條件爲止。
(2)最小卡方值大於卡方閾值(chi2_threshold):根據自由度和顯著性水平得到對應的卡方閾值,如果分箱的各區間最小卡方值小於卡方閾值,則繼續合併,直到最小卡方值超過設定閾值爲止。
可以兩個同時用,也可以只用一個。看實際需求調整即可。
閾值的意義
類別和屬性獨立時,有90%的可能性,計算得到的卡方值會小於4.6。 大於閾值4.6的卡方值就說明屬性和類不是相互獨立的,不能合併。如果閾值選的大,區間合併就會進行很多次,離散後的區間數量少、區間大。
需要補充說明的是,
- 卡方閾值的確定:可以根據顯著性水平(significance level) 和自由度(degree of freedom)求得。
自由度一般情況下爲類別數減一,如分爲3類,自由度爲2。則在90%置信度(10%顯著水平)下,卡方閾值爲4.6。
例如:有3類,自由度爲2,則90%置信度(10%顯著性水平)下,卡方的值爲4.6。
顯著性水平的值需要由用戶指定,這也是chi2分箱改進的地方。 - 閾值的意義:類別和屬性獨立時,有90%的可能性,計算得到的卡方值會小於4.6。
大於4.6的卡方值就說明屬性和類不是相互獨立的,不能合併。閾值越大,區間合併越頻繁,離散後的區間數量越少,區間越大。
3. 分箱結果評價
分箱完成後,要對分箱結果進行評價。評分卡模型中最常用的是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
4. 分箱的優點
- 離散特徵的增加和減少都很容易,易於模型的快速迭代;
- 稀疏向量內積乘法運算速度快,計算結果方便存儲,容易擴展;
- 列表內容離散化後的特徵對異常數據有很強的魯棒性:比如一個特徵是年齡>30是1,否則0。如果特徵沒有離散化,一個異常數據“年齡300歲”會給模型造成很大的干擾;
- 列表內容邏輯迴歸屬於廣義線性模型,表達能力受限;單變量離散化爲N個後,每個變量有單獨的權重,相當於爲模型引入了非線性,能夠提升模型表達能力,加大擬合;
- 離散化後可以進行特徵交叉,由M+N個變量變爲M*N個變量,進一步引入非線性,提升表達能力;
- 列表內容特徵離散化後,模型會更穩定,比如如果對用戶年齡離散化,20-30作爲一個區間,不會因爲一個用戶年齡長了一歲就變成一個完全不同的人。當然處於區間相鄰處的樣本會剛好相反,所以怎麼劃分區間是門學問;
- 特徵離散化以後,起到了簡化了邏輯迴歸模型的作用,降低了模型過擬合的風險。 可以將缺失作爲獨立的一類帶入模型。
- 將所有變量變換到相似的尺度上。