一、引言
分類決策樹是一種基於特征對實例進行划分的樹形結構。如下圖:
圖中包括有內部節點和葉子節點,葉子節點表示的是分類結果,而內部節點表示基於特征對實例的划分。如根節點,是根據特征x1是否大於a1進行划分,划分成兩個內部節點,但是此時的兩個內部節點各自所包含的實例中依然有不同類別的實例,需要進一步划分;於是在x1<a1(左子樹)的實例中,根據特征x2是否大於a2再次進行划分,此時划分出來的兩部分實例,屬於同一部分的實例都屬於同一類別,於是划分完畢。
從圖上的根節點到最末的葉子節點的每一條路徑都是一條划分規則,這些不同的划分規則互斥且完備,也就是說,不存在一個實例,能夠通過兩條以上的路徑進行划分。決策樹學習的目標就是從訓練數據集中學習到這一組分類規則,使得能夠對實例進行正確的分類。
決策樹的生成過程是根據局部最優的原則生成的,即這個學習過程是遞歸地選擇一個最優特征,使得對數據集有個最好的分類,最好的分類就是這些子集能夠被基本正確分類甚至是已經達成葉節點的目標了,那么怎么具體用算式的方法去衡量數據集有了最好的分類呢?也就是說衡量選擇的這個特征好不好的具體標准是什么?這個涉及到下面的信息增益和信息增益比。
能夠對數據集進行分類的樹模型不是唯一的。當特征很多的時候,特征選擇的先后也會起到影響,也就是說需要對特征進行選擇,如上所述每次選擇最優的特征,好過每次隨機進行選取一個特征可能造成的模型復雜度大。(特征選擇)
決策樹的一條自上而下的路徑表示一個條件概率分布,越深的樹它的概率模型復雜度就越大;有時候對樹進行過於完美的細分不是一件好事,可能會造成過擬合。考慮到這兩點,就有了決策樹的剪枝,自下而上去掉一些分類過細的葉節點,使其退回到更高的節點。決策樹的剪枝相對於決策樹的生成,剪枝考慮的是全局最優,生成是局部最優。(剪枝)
二、信息增益和信息增益比
熵H(p)可以度量隨機變量X的不確定性,這里的隨機變量X是離散情況下可以通過下式計算熵:
當X取值為二值時,熵的變化曲線如下:
也就是說,當概率p=0.5時,即X取1的概率是0.5,取0的概率是0.5,此時隨機變量具有最大的不確定性,通俗理解為:我們認為它取1或取0都是有道理的,如果說該X取1的概率為0.7,取0的概率為0.3,此時隨機變量X的不確定性就沒有前者那么大,畢竟我們明確的知道了X更可能取1。
以上是熵的定義,接下來解釋條件熵:
就5.5這個式子而言,左式的H(Y|X)和右式的H(Y|X=xi)特別像,但是也是有區別的。左式H(Y|X)表示已知隨機變量X的條件下隨機變量Y的不確定性,含義就是這個已知的X可以是任何值;而右式中的H(Y|X=xi)表示的是隨機變量X具體取某一個值下隨機變量Y的不確定性,故它們的差別可以通過加上一個求和項來填補,只要將X取所有可能值的熵都加和,不就得到了左式嗎?
當熵和條件熵中涉及的概率是由數據估計得到的,即通過我們的已有訓練數據集得到的時候,我們稱之為“經驗”,即為經驗熵和經驗條件熵。
再回到最開始說明的決策樹生成的過程,每一次遍歷的選擇一個最佳特征進行分類,這個特征要使得分類效果最好。衡量這一點的就有信息增益和信息增益比。假如我們不做特征選擇,直接分,那么我們得到的就是最原始的這個數據集D的經驗熵H(D),如果就這么拿去給新樣本進行分類,有很大的不確定性——就像我們有一組關於男女外貌特征及性別(分類類別)的數據集,數據中顯示留長發(特征)的人都是女性,然后再給一個新樣本,我們就可以根據新樣本是否留長發來判斷他的性別,這樣通過特征來判斷類別有助於提高准確性,假如我們不這樣做,單純給一個新樣本,我們只好說新樣本是女性的概率為0.5。
信息增益和信息增益比可以衡量這個特征的選擇好還是不好。因為信息增益表示特征X的信息使得類別Y的信息不確定性減少的程度,如果在眾多特征中選擇一個特征,使得此時的信息增益比選擇其他特征都大,那么這個特征就是一個當下最好的特征,它可以極大的降低分類過程中信息的不確定性。
那么5.6中的集合D的經驗熵H(D)具體要怎么算,參考下圖的例子:
在不進行特征選擇的時候,H(D)只用數一下類別Y即可,即上圖的最后一列,這個數據表里的類別一共有6個‘否’,9個‘是’,通過數據估計的方法我們計算可得:
$H\left ( D \right )= -\frac{9}{15}log\left ( \frac{9}{15} \right )-\frac{6}{15}log\left ( \frac{6}{15} \right )$
給定特征條件A下D的經驗條件熵H(D|A)稍微復雜一點,我們要先數出特征A中的比例,假設這里選擇“有工作”這列作為特征A,有10個‘否’,5個‘是’;然后我們在這10個‘否’中數類別,有6個‘否’,4個‘是’;在特征A中的5個‘是’里操作相同:
$H\left ( D \right )= \frac{10}{15}[-\frac{6}{10}log\left ( \frac{6}{10} \right )-\frac{4}{10}log\left ( \frac{4}{10} \right )]+\frac{5}{15}[-\frac{5}{5}log\left ( \frac{5}{5} \right )-\frac{0}{5}log\left ( \frac{0}{5} \right )]$
二者之差就是信息增益。
但是,如果特征的取值有更多可能,就意味着經驗條件熵會更小,二者差值會更大,信息增益會更大,這樣在訓練數據的時候,會偏向於選擇取值較多的特征,這並不意味這這些特征更好(如上表的年齡和信貸情況特征),我們要排除掉因為特征取值個數不同所造成的差異,就有了信息增益比。信息增益比就是在我們計算出信息增益后,再除以這個特征A本身的熵,以上述為例,特征‘有工作’的熵為:
$-\frac{10}{15}log\left ( \frac{10}{15} \right )-\frac{5}{15}log\left ( \frac{5}{15} \right )$
這里有一個問題,特征的取值有更多的可能,熵就會越小?腦子里想了一下log函數關於x軸的翻轉,好像簡單通過圖像無法進行解釋,可能還得有空翻一下論文。
三、ID3算法和C4.5算法
二者的區別就在於生成樹的時候采用信息增益原則還是信息增益比原則,ID3使用信息增益,C4.5使用信息增益比。既然差不多就一塊講了吧。
有兩種特殊情況先事先說明,一是數據集所有樣本都同屬一個類別的時候,此時為單節點樹,直接返回就好;二是沒有特征,都沒特征了所以也就不涉及特征選擇啥的,那就看一下樣本集里哪種類別多,就返回那個類別的節點就好,也是單節點樹。
好,除了這兩種特殊情況以外,剩下的情況就是,數據集有多個類別,也有特征供我們進行選擇的正常情況了。先貼一個算法,然后在下面進行通俗解釋:
就是排除了特殊情況后,設定一個閾值,先把所有特征的信息增益或信息增益比算出來,和閾值進行比較,這個閾值通常是一個比較小的值,如果發現所有特征的信息增益都比這個閾值還小,那么這個樹就不用生成了,因為這些特征都不能很好的體現不同類別的區別,何必費心,直接定為單節點樹算了。
但是如果並非所有的特征計算得信息增益都小於這個閾值,那就先把最大的那個特征先選咯,選擇了這個特征就意味着我們把數據集進行了一個划分,分成了不同塊,然后我們現在就要在每一塊數據集里重復我們的上述工作:判斷類別,判斷是否有可選特征,判斷與閾值的比較,選擇最大的特征。這里要注意,已經選擇過的特征不可以再進行選擇。
那么什么時候終結呢,直到沒有特征選了,知道划分出來的這塊數據的類別相同了,直到剩下的特征的信息增益都小於我定的閾值了,這三點滿足任意一個都可以終結該子樹。
四、剪枝
剛開始機器學習的時候,會提到一個詞‘過擬合’。在決策樹里也有過擬合,就是一昧追求將訓練數據完美划分,而將樹分得過細,造成在生成子樹的后期,會花很多的代價,只為提高一點點的准確率;還有就是這棵樹,對訓練集分類效果好,對任意一個新樣本可就不一定了,樹越細,就為新樣本提了更多的要求,分錯的概率也大大提高了,這新樣本哪怕有一點不對,就會錯過它的正確類別。
剪枝就是將已經生成的樹進行簡化,裁掉一些子樹或者葉節點,使其退回到父節點。然后這里也涉及到剪枝的原則,剪枝的原則是整棵樹損失函數極小化,那樹的損失函數又怎么進行衡量呢,最小化又怎么計算呢?
先思考決策樹的損失是什么,損失來源是什么,主要原因就是葉子節點太多了!不是說數據集的類別多,而是說分出來的葉子節點,其中可能有很多重復的類別。葉子節點多意味着分得細,就像前面說的,剛開始選擇特征進行分類,可以使信息的不確定性下降90%,而到了后期,再選擇特征分類時,可能只能使不確定性下降0.00001%,那花了同樣的精力得到的回報實在太少,索性不干了!
於是,決策樹的損失可以如下衡量:
也就是說,我們將每個葉節點上的樣本數,乘以其葉節點的經驗熵,就是決策樹學習損失函數的第一項。如果一個葉節點上的所有樣本都同屬一個類別的話,其不確定性是最小的,即熵為0,因為給一個樣本,只要樣本它能夠通過層層划分到這個葉節點,我們就可以確定的說出這個樣本的類別。那么是否存在有的葉節點的熵不為0呢?也就是說是否一個葉子節點上的所有樣本的類別不同?是有可能的,因為前面提到樹的生成的時候講到一個“閾值”,如果剩下來的特征的信息增益沒那么大的時候,也就是說這些特征都不能夠對樣本進行一個很好的分類時,我們就不會再選擇樣本,而是讓此時節點中 樣本類別占比最大的 類別 作為該節點的分類結果,這種時候葉節點的經驗熵就很可能不為0,而是一個大於0的數。
那么,經驗熵為0和經驗熵不為0,哪個更好一點,按照我們現在的說法來說,分的太細了肯定不好,就是說熵為0的時候不好,但是,分得太細和熵為0是等同關系嗎?不是的,還有一些決策樹,因為樣本或特征選擇優秀,就能夠很好很准確的划分開數據集的類別。比如前面提到了,用[是否長發]這個特征來判斷男女,這樣划分出來的類別肯定是不准的,因為總有男生喜歡飄逸,也有女生走帥氣風,那么我們的數據集里如果能采取到[DNA]呢?就能夠很准確的划分開男女類別,並且只選取了一個特征,這種情況下的葉節點的熵也是0,但是就是一個非常簡短且優秀的決策樹。
所以單單從經驗熵來判斷完全不夠,就必須有損失函數的第二項,T是樹的節點數,第二項就是說明如果一棵樹它的節點很多,這種樹是不好的。因為節點多,說明這棵樹,選了一個特征分不全,再選一個,再選一個...就會分得很細,即便它最后的葉子節點分得很准確(熵為0),但是由於節點數過多,損失函數依舊很大。
最好的樹就是,僅用幾個特征就分好了,而且葉子節點中只包含同一類的樣本。除此之外的樹的損失函數值都很大,都不大好。
不好的樹就要進行剪枝,剪枝的思想其實比較簡單,從下往上剪,分別計算剪枝前和剪枝后的損失函數值,如果剪枝后損失函數值有所下降,那么就可以剪枝,否則就不要剪。
先挖個坑,這里要用動態規划的方法來剪,等有空了去翻一下論文先。
五、項目
def createBranch(): 檢測數據集中的所有數據的分類標簽是否相同: If so return 類標簽 Else: 尋找划分數據集的最好特征(划分之后信息熵最小,也就是信息增益最大的特征) 划分數據集 創建分支節點 for 每個划分的子集 調用函數 createBranch (創建分支的函數)並增加返回結果到分支節點中 return 分支節點
案例需求:我們采集海洋生物數據信息,選擇其中5條如下表所示,從諸多特征中選擇2個最主要特征,以及判定是否屬於魚類(此處我們選擇二分類法即只考慮魚類和非魚類)。根據這些信息如何創建一個決策樹進行分類並可視化展示?
根據上表,先把數據輸進去,如果是很多的數據,就要通過pandas進行導入了:
#創建數據集,返回數據集和特征名 def createDataSet(): dataSet = [[1, 1, 'yes'], [1, 1, 'yes'], [1, 0, 'no'], [0, 1, 'no'], [0, 1, 'no']] labels = ['no surfacing', 'flippers'] return dataSet, labels # 1 打印數據集和標簽 dataset,label=createDataSet() print(dataset) print(label)
輸出結果:
#計算熵 def calcShannonEnt(dataSet): # 需要對 list 中的大量計數時,可以直接使用Counter,不用新建字典來計數 label_count = Counter(data[-1] for data in dataSet) # 統計標簽出現的次數 probs = [p[1] / len(dataSet) for p in label_count.items()] # 計算概率 shannonEnt = sum(np.array([-p * np.math.log(p, 2) for p in probs]))# 計算香農熵 print(de.Decimal(shannonEnt).quantize(de.Decimal('0.00000'))) return shannonEnt # 2 計算數據集的熵 calcShannonEnt(dataset)
輸出結果為0.97095,這是原數據集的熵,也就是不考慮特征,只通過數類別的個數然后再套用熵公式算出來的。主要是因為這里的五個樣本中類別的分布是2:3,接近1:1,即在不給特征的時候,判斷一個樣本的類別的准確率很接近0.5,p=0.5時數據的不確定性最大。這里接近0.5,也可以認為此時不確定性很大。
接下來要做的就是選擇一個信息增益最大的特征進行分類,信息增益最大等價於特征的經驗條件熵最小,經驗條件熵最小可以說明這個特征的分類能夠達到最好的准確率。
先跳過特征條件熵的計算,看如何基於某個特征划分數據集:
#根據特征划分數據集 def splitDataSet(dataSet, index, value): retDataSet = [] for featVec in dataSet: if featVec[index] == value:# 判斷index列的值是否為value reducedFeatVec = featVec[:index] # [:index]表示取前index個特征 reducedFeatVec.extend(featVec[index+1:]) # 取接下來的數據 retDataSet.append(reducedFeatVec) print(retDataSet) return retDataSet #3 划分數據集 splitDataSet(dataset,0,1)#根據第0個特征是否為1進行划分
這三個返回的樣本都是第0個特征為1的樣本點。
接下來把計算集合熵,和特征選擇部分結合:
#選擇最好的特征 def chooseBestFeatureToSplit(dataSet): base_entropy = calcShannonEnt(dataSet) # 計算初始香農熵 best_info_gain = 0 best_feature = -1 # 遍歷每一個特征 for i in range(len(dataSet[0]) - 1): # 對當前特征進行統計 feature_count = Counter([data[i] for data in dataSet]) # 計算分割后的香農熵 new_entropy = sum(feature[1] / float(len(dataSet)) * calcShannonEnt(splitDataSet(dataSet, i, feature[0])) for feature in feature_count.items()) # 更新值 info_gain = base_entropy - new_entropy # print('No. {0} feature info gain is {1:.3f}'.format(i, info_gain)) if info_gain > best_info_gain: best_info_gain = info_gain best_feature = i print(best_feature) return best_feature # 4 選擇最好的數據集划分方式 chooseBestFeatureToSplit(dataset)
輸出為0,說明0是最好的特征,我們要選擇特征0進行划分。
接下來構建樹,構建出這個樹后,可以進行保存反復使用:
def majorityCnt(classList): major_label = Counter(classList).most_common(1)[0] print('sortedClassCount:', major_label[0]) return major_label[0] def createTree(dataSet, labels): classList = [example[-1] for example in dataSet]#翻轉數據集 # 如果數據集的最后一列的第一個值出現的次數=整個集合的數量,也就說只有一個類別,就只直接返回結果就行 # 第一個停止條件:所有的類標簽完全相同,則直接返回該類標簽。 # count() 函數是統計括號中的值在list中出現的次數 if classList.count(classList[0]) == len(classList): return classList[0] # 如果數據集只有1列,那么最初出現label次數最多的一類,作為結果 # 第二個停止條件:使用完了所有特征,仍然不能將數據集划分成僅包含唯一類別的分組。 if len(dataSet[0]) == 1: return majorityCnt(classList) # 選擇最優的列,得到最優列對應的label含義 bestFeat = chooseBestFeatureToSplit(dataSet) # 獲取label的名稱 bestFeatLabel = labels[bestFeat] # 初始化myTree myTree = {bestFeatLabel: {}} # 所以這行代碼導致函數外的同名變量被刪除了元素,造成例句無法執行,提示'no surfacing' is not in list #del(labels[bestFeat]) # 取出最優列,然后它的branch做分類 featValues = [example[bestFeat] for example in dataSet] uniqueVals = set(featValues) for value in uniqueVals: # 求出剩余的標簽label subLabels = labels[:] # 遍歷當前選擇特征包含的所有屬性值,在每個數據集划分上遞歸調用函數createTree() myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet, bestFeat, value), subLabels) # print('myTree', value, myTree) print(myTree) return myTree
輸出結果為:{'no surfacing': {0: 'no', 1: {'flippers': {0: 'no', 1: 'yes'}}}}
解讀一下就是先對'no surfacing'特征划分,再對'flippers’划分。
然后有了樹,也有了特征選取,也有了熵的計算,現在開始進行類別的判定:
def classify(inputTree, featLabels, testVec): firstStr = list(inputTree.keys())[0] # 獲取tree的根節點對於的key值 secondDict = inputTree[firstStr] # 通過key得到根節點對應的value # 判斷根節點名稱獲取根節點在label中的先后順序,這樣就知道輸入的testVec怎么開始對照樹來做分類 featIndex = featLabels.index(firstStr) for key in secondDict.keys(): if testVec[featIndex] == key:#如果測試數據的特征等於中間節點的key if type(secondDict[key])is dict: classLabel = classify(secondDict[key], featLabels, testVec) else: classLabel = secondDict[key] print(classLabel) return classLabel
再把所有的聯合一下:
def fishTest(): # 1.創建數據和結果標簽 myDat, labels = createDataSet() # 計算label分類標簽的香農熵 calcShannonEnt(myDat) # 求第0列 為 1/0的列的數據集【排除第0列】 print('1---', splitDataSet(myDat, 0, 1)) print('0---', splitDataSet(myDat, 0, 0)) # 計算最好的信息增益的列 print(chooseBestFeatureToSplit(myDat)) import copy myTree = createTree(myDat, copy.deepcopy(labels)) # [1, 1]表示要取的分支上的節點位置,對應的結果值 print(classify(myTree, labels, [1, 1])) fishTest()
結果為:
全代碼如下:
import decimal as de import numpy as np from collections import Counter #創建數據集,返回數據集和特征名 def createDataSet(): dataSet = [[1, 1, 'yes'], [1, 1, 'yes'], [1, 0, 'no'], [0, 1, 'no'], [0, 1, 'no']] labels = ['no surfacing', 'flippers'] return dataSet, labels #計算熵 def calcShannonEnt(dataSet): # 需要對 list 中的大量計數時,可以直接使用Counter,不用新建字典來計數 label_count = Counter(data[-1] for data in dataSet) # 統計標簽出現的次數 probs = [p[1] / len(dataSet) for p in label_count.items()] # 計算概率 shannonEnt = sum(np.array([-p * np.math.log(p, 2) for p in probs]))# 計算香農熵 #print(de.Decimal(shannonEnt).quantize(de.Decimal('0.00000'))) return shannonEnt #根據特征條件熵划分數據集 def splitDataSet(dataSet, index, value): retDataSet = [] for featVec in dataSet: if featVec[index] == value:# 判斷index列的值是否為value reducedFeatVec = featVec[:index] # [:index]表示取前index個特征 reducedFeatVec.extend(featVec[index+1:]) # 取接下來的數據 retDataSet.append(reducedFeatVec) #print(retDataSet) return retDataSet #選擇最好的特征 def chooseBestFeatureToSplit(dataSet): base_entropy = calcShannonEnt(dataSet) # 計算初始香農熵 best_info_gain = 0 best_feature = -1 # 遍歷每一個特征 for i in range(len(dataSet[0]) - 1): # 對當前特征進行統計 feature_count = Counter([data[i] for data in dataSet]) # 計算分割后的香農熵 new_entropy = sum(feature[1] / float(len(dataSet)) * calcShannonEnt(splitDataSet(dataSet, i, feature[0])) for feature in feature_count.items()) # 更新值 info_gain = base_entropy - new_entropy # print('No. {0} feature info gain is {1:.3f}'.format(i, info_gain)) if info_gain > best_info_gain: best_info_gain = info_gain best_feature = i #print(best_feature) return best_feature def majorityCnt(classList): major_label = Counter(classList).most_common(1)[0] #print('sortedClassCount:', major_label[0]) return major_label[0] def createTree(dataSet, labels): classList = [example[-1] for example in dataSet]#翻轉數據集 # 如果數據集的最后一列的第一個值出現的次數=整個集合的數量,也就說只有一個類別,就只直接返回結果就行 # 第一個停止條件:所有的類標簽完全相同,則直接返回該類標簽。 # count() 函數是統計括號中的值在list中出現的次數 if classList.count(classList[0]) == len(classList): return classList[0] # 如果數據集只有1列,那么最初出現label次數最多的一類,作為結果 # 第二個停止條件:使用完了所有特征,仍然不能將數據集划分成僅包含唯一類別的分組。 if len(dataSet[0]) == 1: return majorityCnt(classList) # 選擇最優的列,得到最優列對應的label含義 bestFeat = chooseBestFeatureToSplit(dataSet) # 獲取label的名稱 bestFeatLabel = labels[bestFeat] # 初始化myTree myTree = {bestFeatLabel: {}} # 所以這行代碼導致函數外的同名變量被刪除了元素,造成例句無法執行,提示'no surfacing' is not in list del(labels[bestFeat]) # 取出最優列,然后它的branch做分類 featValues = [example[bestFeat] for example in dataSet] uniqueVals = set(featValues) for value in uniqueVals: # 求出剩余的標簽label subLabels = labels[:] # 遍歷當前選擇特征包含的所有屬性值,在每個數據集划分上遞歸調用函數createTree() myTree[bestFeatLabel][value] = createTree(splitDataSet(dataSet, bestFeat, value), subLabels) # print('myTree', value, myTree) print(myTree) return myTree def classify(inputTree, featLabels, testVec): firstStr = list(inputTree.keys())[0] # 獲取tree的根節點對於的key值 secondDict = inputTree[firstStr] # 通過key得到根節點對應的value # 判斷根節點名稱獲取根節點在label中的先后順序,這樣就知道輸入的testVec怎么開始對照樹來做分類 featIndex = featLabels.index(firstStr) for key in secondDict.keys(): if testVec[featIndex] == key: if type(secondDict[key]) is dict: classLabel = classify(secondDict[key], featLabels, testVec) else: classLabel = secondDict[key] #print(classLabel) return classLabel def fishTest(): # 1.創建數據和結果標簽 myDat, labels = createDataSet() # 計算label分類標簽的香農熵 calcShannonEnt(myDat) # 求第0列 為 1/0的列的數據集【排除第0列】 print('1---', splitDataSet(myDat, 0, 1)) print('0---', splitDataSet(myDat, 0, 0)) # 計算最好的信息增益的列 print(chooseBestFeatureToSplit(myDat)) import copy myTree = createTree(myDat, copy.deepcopy(labels)) # [1, 1]表示要取的分支上的節點位置,對應的結果值 print(classify(myTree, labels, [1, 1])) fishTest()