機器學習 - 決策樹


決策樹,聽名字就知道跟樹有關,而且很容易猜到是一種類似依靠樹形結構來輔助決策過程的策略。所以重點就是如何構建這個樹,如何依次選取樹的各個節點,以便能在測試集中有較好的表現。

信息熵與信息增益

說到如何選取節點,就要引入信息熵的概念。我以前一看到“熵”這個字就頭疼,以為是跟高深的物理學相關,其實很好理解,簡單說就是純度。假設有一罐混合了氧氣和二氧化碳的氣體:

我們通常會說這罐氣體不純,那么怎么來度量這個純度呢?假設氧氣占20%,二氧化碳占80%,則可以看做是二氧化碳里混入了少量的氧氣,二氧化碳相對純一些;如果看做是氧氣中混入了大量的二氧化碳,那么這個氧氣也太不純了。我們在這里所討論的純度,都是針對某一特定對象而言,而又不適用於這個系統里的其他對象。如果把這個罐子當做整個系統的話,信息熵就可以看做是系統級的純度。一般這樣度量信息熵,系統純度越低,信息熵越大,反之,系統純度越高,信息熵越小。如果罐子里只剩一種氣體,則信息熵為0。
信息熵的計算公式如下:

其中k表示系統中特征的數量,p(xi)表示每個特征再系統中的占比。所以我們可以算出此時的信息熵為:

假設由於保存不當,罐子中混入了一種有色氣體(比如二氧化硫):

假設目前三種氣體的占比為:氧氣15%,二氧化碳50%,二氧化硫35%,根據信息熵的理論,現在整個系統的信息熵應該比原先更大了(純度降低)。我們不妨再算一下此時的信息熵:

可以看到信息熵增大了,符合之前的理論。那么如果我們現在要分離這三種氣體,就需要選擇一個標准,或者說,選擇能夠區分這三種氣體的特征進分離。最直觀的特征就是有色跟無色:

如果按這個特征對系統進行划分,則會將系統划分為有色氣體跟無色氣體兩個子集。划分后的系統,已經由最初較為混沌的狀態(三種氣體混合)變成了有色跟無色兩部分,所以,此時的信息熵就變成了有色子集的信息熵與無色子集信息熵的加和。但考慮到這兩類氣體在系統中的占比,需要將占比作為子集信息熵的權重,所以此時的信息熵為:

所以經過對氣體顏色這一特征的划分,系統的信息熵由1.125變成了0.418,說明系統純度有所提升。為了准確的表示提升的具體情況,就把這個提升空間叫做信息增益

寫成標准式:

其中,D表示整個樣本數據集,a表示所選的用戶划分系統樣本的特征,Ent(D)表示划分前的信息熵,|Di|表示划分后的每個子集的樣本個數,|D|表示划分前的樣本總數,Ent(Di)表示每個子集各自的信息熵。后面一項實際上就是子集信息熵的期望。
從公式可以看出,如果選取不同的特征,划分后的信息熵可能會有大小之分,而系統當前的信息熵是不變的,所以划分后的信息熵如果越小,信息增益就越大,說明系統純度提升的幅度就越大,反之亦然。所以,我們就需要遍歷所有已知特征,找出能夠提升幅度最大的那個特征,作為首選的划分特征。
至此,就把信息熵和信息增益的概念介紹清楚了,雖然有點啰嗦,但是應該是比較通俗易懂的。我們上面介紹的這種選取划分特征的算法也叫做ID3算法。下面來看西瓜書中對應的例子。

ID3算法


按照上面的套路,我們先取色澤作為划分特征,計算一下對應的信息增益。
首先,系統當前有8個好瓜,9個壞瓜,所以對應 信息熵為:

我們再選色澤作為划分特征,計算一下子集信息熵的期望:

其中:

帶入上式,得:

再依次計算出其他特征對應的信息增益,取信息增益最大的那個特征作為首選條件。例如對於初始數據集,各特征的信息增益大小如下圖所示:

再如此繼續划分下去,就可以得到一個樹形結構的分支圖,即我們要的決策樹。
退出條件:
1.划分子集的信息熵為0;
2.無可用特征,取當前集合占比最大的作為標簽。
下面我們用Python來實現。首先要把圖4.1的文字轉為csv文件的格式:

我們只要從csv里讀取數據,就能進行后續的分析了。ID3的Python實現如下:

class DTree:
    # 綜合ID3和C4.5算法,初始化時需要選擇type類型,默認或0為ID3,1為C4.5
    def __init__(self, type=0):
        self.dataset = ''
        self.model = ''

    def load_data(self, data):
        dataset = np.loadtxt(data, delimiter=',', dtype=str)
        self.dataset = dataset

    def get_entropy(self, dataset):
        # 統計總數及正反例個數
        sum_num = len(dataset[1:])
        p1 = dataset[1:, -1].astype(int).sum() / sum_num
        p2 = 1 - p1
        # 如果p1或p2有一個為0,說明子集純度為0,,直接返回0
        if p1==0 or p2==0:
            return 0
        # 使用公式計算信息熵並返回
        return -1*(p1*math.log2(p1) + p2*math.log2(p2))

    def get_max_category(self, dataset):
        pos = dataset[1:, -1].astype(int).sum()
        neg = len(dataset[1:, -1]) - pos
        return '1' if pos > neg else '0'

    def dataset_split(self, dataset, feature, feature_value):
        index = list(dataset[0, :-1]).index(feature)
        # 遍歷特征所在列,剔除值不等於feature_value的行
        j = 0
        for i in range(len(dataset[1:, index])):
            if dataset[1:, index][j] != feature_value:
                dataset = np.delete(dataset, j+1, axis=0)
                j -= 1
            j += 1
        # 刪除feature所在列
        return np.delete(dataset, index, axis=1)

    def get_best_feature(self, dataset, E):
        feature_list = dataset[0, :-1]
        feature_gains = {}
        for i in range(len(feature_list)):
            # 分別統計在每個特征值划分下的信息增益
            feature_values = np.unique(dataset[1:, i])
            feature_sum = len(dataset[1:])  # 減去第一行標題
            # 累加子集熵
            sub_entropy_sum = 0
            for value in feature_values:
                # 按值划分子集
                subset = self.dataset_split(dataset, feature_list[i], value)
                subset_sum = len(subset[1:])    # 減去第一行標題
                # 計算子集熵
                sub_entropy = self.get_entropy(subset)
                # 權重
                w = subset_sum/feature_sum
                # 匯總當前特征下的子集熵*個數權重
                sub_entropy_sum += w*sub_entropy
            # 根據算公式計算信息增益
            feature_gains[feature_list[i]] = E-sub_entropy_sum
        print(feature_gains)
        # 返回最大信息增益對應的特征及索引
        max_gain = max(feature_gains.values())
        for feature in feature_gains:
            if feature_gains[feature] == max_gain:
                index = list(feature_list).index(feature)
                return feature, index

    def build_tree(self, dataset):
        # 計算數據集信息熵
        E = self.get_entropy(dataset)
        # 設置退出條件
        # 1.如果集合的信息熵為0,則返回當前標簽
        if E == 0:
            return dataset[1][-1]
        # 2.特征數為1,說明無可划分特征,返回當前集合中占比最多的標簽
        if len(dataset[0]) == 2:    # 特征+標簽
            return self.get_max_category(dataset)
        # 獲取最佳特征
        feature, index = self.get_best_feature(dataset, E)
        # 按特征划分子集
        tree = {feature:{}}
        # 獲取特征值
        feature_values = np.unique(dataset[:, index][1:])
        # 按特征值划分子集
        for value in feature_values:
            subset = self.dataset_split(dataset, feature, value)
            subtree = self.build_tree(subset)
            tree[feature][value] = subtree
        return tree

    def train(self):
        self.model = self.build_tree(self.dataset)
        return self.model

    def predict(self, testset):
        # 取特征列表
        feature_list = testset[0]
        # 取測試數據(排除特征及label)
        test_data = testset[1:, :-1]
        print(test_data)
        # 取真實label
        real_list = testset[1:, -1]
        # 預測label
        pre_list = []
        # 逐行遍歷測試集
        for i in range(len(test_data)):
            # 初始化tree
            tree = self.model
            # 當tree不是標簽時,則進行遍歷
            while tree not in ['0', '1']:
                # 取當前tree的根節點root
                root = list(tree.keys())[0]
                brunch = tree[root]     # 獲取子節點
                feature_index = list(feature_list).index(root)    # 獲取根節點對應的特征索引
                # 遍歷各分支,如果分支的值等於對應特征的值,則選取分支的value為新的tree
                for brunch_value in brunch.keys():
                    if brunch_value == test_data[i][feature_index]:
                        tree = brunch[brunch_value]
                        break
                continue
            # tree如果為標簽值,則直接標注  
            pre_list.append(tree)
            # 預測完當前數據后重置tree
            continue
        # 計算准確率
        accurate = 0
        for i in range(len(real_list)):
            if real_list[i] == pre_list[i]:
                accurate += 1
        return accurate / len(real_list)


dtree = DTree()
dtree.load_data('data4_1.csv')
tree_model = dtree.train()
print(tree_model)

分類結果:

可以傳入一些測試數據進行預測,並計算預測的准確率:

# 加載數據
dtree = DTree()
dtree.load_data('data4_1.csv')
# 訓練模型
tree_model = dtree.train()
# 使用測試集預測
testset = np.array([['color', 'root', 'knock', 'texture', 'umbilicus', 'touch', 'label'],
 ['0', '1', '0', '0', '1', '0', '1'],
 ['1', '0', '1', '1', '0', '0', '0'],
 ['2', '1', '0', '1', '1', '0', '1'],
 ['0', '2', '1', '2', '0', '0', '0'],
 ['2', '0', '0', '1', '0', '0', '1']
 ])
accurate = dtree.predict(testset)
print('正確率為:' + str(accurate))

# 輸出結果
真實值:['1' '0' '1' '0' '1']
預測值:['1', '0', '0', '0', '0']
正確率為:0.6

這里的測試集我是隨便給的,因為所有樣本都用來訓練了。
缺點:如果把編號也作為樣本特征的話,那么它的信息增益為0.758,大於所有其他特征的信息增益,說明特征值種類越多,信息增益趨向於越大。

通過增益率改良后的C4.5算法

C4.5算法旨在消除這種由特征值種類差異所引起的“不平等待遇”。它引入了特征的“固有值”的概念,相當於對該特征的種類及數量計算信息熵。而這種“固有值”也擁有這種“不平等待遇”(種類越多,信息增益越大),所以兩者相除,正好抵消了這種差異:
固有值的計算公式:

信息增益在C4.5算法下的計算公式:

由於C4.5與ID3的區別只是計算公式的不同,所以在獲取最佳特征的函數get_best_feature()中稍作修改即可:

def get_best_feature(self, dataset, E):
    feature_list = dataset[0, :-1]
    feature_gains = {}
    for i in range(len(feature_list)):
        # 分別統計在每個特征值划分下的信息增益
        feature_values = np.unique(dataset[1:, i])
        feature_sum = len(dataset[1:])  # 減去第一行標題
        # 累加子集熵
        sub_entropy_sum = 0
        for value in feature_values:
            # 按值划分子集
            subset = self.dataset_split(dataset, feature_list[i], value)
            subset_sum = len(subset[1:])    # 減去第一行標題
            # 計算子集熵
            sub_entropy = self.get_entropy(subset)
            # 權重
            w = subset_sum/feature_sum
            # 匯總當前特征下的子集熵*個數權重
            sub_entropy_sum += w*sub_entropy
        # 根據算公式計算信息增益
        feature_gains[feature_list[i]] = E-sub_entropy_sum
    print(feature_gains)
    # 返回最大信息增益對應的特征及索引
    max_gain = max(feature_gains.values())
    for feature in feature_gains:
        if feature_gains[feature] == max_gain:
            index = list(feature_list).index(feature)
            return feature, index

得到的分類結果:

我們使用同樣的測試集,再進行預測:

# 新增了self.type存放算法類型,默認0為ID3, 1為C4.5
dtree = DTree(type=1)  # 使用C4.5算法
dtree.load_data('data4_1.csv')
tree_model = dtree.train()
# 使用測試集預測
testset = np.array([['color', 'root', 'knock', 'texture', 'umbilicus', 'touch', 'label'],
 ['0', '1', '0', '0', '1', '0', '1'],
 ['1', '0', '1', '1', '0', '0', '0'],
 ['2', '1', '0', '1', '1', '0', '1'],
 ['0', '2', '1', '2', '0', '0', '0'],
 ['2', '0', '0', '1', '0', '0', '1']
 ])
accurate = dtree.predict(testset)
print('正確率為:' + str(accurate))

# 輸出結果
真實值:['1' '0' '1' '0' '1']
預測值:['1', '0', '0', '0', '0']
正確率為:0.6

預測結果與ID3相同。

剪枝優化

剪枝的目的在於防止算法對訓練數據過擬合,訓練數據中可能存在部分噪聲數據,如果算法對訓練數據擬合得過於完美,則很有可能將噪聲數據也擬合進模型中,從而降低整體的泛化性能。
通常剪枝分為預剪枝和后剪枝。預剪枝即在每次特征划分前都對模型進行性能評估,如果划分后的預測准確率優於划分前,則進行剪枝,否則停止當前節點的剪枝。也就是說,預剪枝與決策樹的搭建是同步進行的。后剪枝就是先讓算法訓練出模型,再自下而上地分析,比較每個節點在剪枝前后的預測准確率。

預剪枝

西瓜書上提供的剪枝方案是拿整棵樹的性能做比較,個人感覺比較難實現,因為每次迭代無法獲取上一輪生成的樹。而且,拿整棵樹比較的話,也是當前划分節點會發生變化,其他原有節點該怎么划分還是怎么划分,不會有影響,所以直接比較當前節點划分前后的准確率即可。


預剪枝步驟實際是在核心的build_tree()方法中的,所以只需要修改這部分的代碼即可,其他相同的代碼不再贅述:

def build_tree(self, dataset):
    # 計算數據集信息熵
    E = self.get_entropy(dataset)
    # 設置退出條件
    # 1.如果集合的信息熵為0,則返回當前標簽
    if E == 0:
        return dataset[1][-1]
    # 2.特征數為1,說明無可划分特征,返回當前集合中占比最多的標簽
    if len(dataset[0]) == 2:    # 特征+標簽
        return self.get_max_category(dataset)
    # 比對特征划分前后的正確率(只比對當前子樹,忽略上一級的樹)
    # 計算初始正確率(所有樣本預測為占比最大的標簽)
    max_label = self.get_max_category(dataset)
    accurate_before = self.predict(max_label)
    # 獲取最佳特征
    feature, index = self.get_best_feature(dataset, E)
    # 建立特征划分后的子樹
    tree_after = {feature:{}}
    # 獲取特征值
    feature_values = np.unique(dataset[:, index][1:])
    # 計算特征划分后的模型正確率
    for value in feature_values:
        subset = self.dataset_split(dataset, feature, value)
        # 獲取每個子集的標簽
        sub_category = self.get_max_category(subset)
        tree_after[feature][value] = sub_category
    # 預測得到剪枝后的准確率
    accurate_after = self.predict(tree_after)
    # 如果剪枝后正確率不大於剪枝前,則返回當前dataset占比最大的標簽,停止當前節點的划分
    if accurate_after <= accurate_before:
        return max_label
    # 如果正確率提升,則繼續划分
    # 按特征划分子集
    tree = {feature:{}}
    # 按特征值划分子集
    for value in feature_values:
        subset = self.dataset_split(dataset, feature, value)
        subtree = self.build_tree(subset)
        tree[feature][value] = subtree
    return tree

基尼系數和后剪枝的內容待補充。。。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM