決策樹

Sion258發表於2024-07-27

決策樹

回顧線性模型

線上性模型中,對於一個擁有i個屬性的資料集我們透過構造$y = WX(W = (w,b)^*,X = (x_1;x_2;...;x_i)$根據均方方差最小化或者對數機率最大化來進行線性迴歸或者對數機率迴歸來確定$W$,得到模型從而模擬得到輸出y與資料集$X$的線性關係,其中對數機率迴歸由於輸出的是機率因此被用作分類任務的一部分。而線性判別分析透過類間散度矩陣$S_b$和類內散度矩陣$S_w$擬合一個目標函式使得$S_b$和$S_w$的廣義瑞利商最大,最後求得$W$的閉式解,透過將輸出的值離散化為類別從而達到分類的目的。具體來說:

線上性模型中,對於一個擁有 $i$個屬性的資料集,我們透過構造 $$y = WX$$(其中 $$W = (w_1, w_2, \ldots, w_i, b)^T$$,$$X = (x_1, x_2, \ldots, x_i, 1)^T$$)(x1,x2,…,xi 是輸入資料的特徵),根據最小化均方誤差來進行線性迴歸,確定 $$W$$ 的取值,從而擬合模型,得到輸出 $$y$$ 與資料集 $$X$$ 之間的線性關係。

對數機率迴歸透過構造 $$P(y=1|X) = \sigma(WX)$$(其中 $$\sigma(z) = \frac{1}{1 + e^{-z}}$$ 是Sigmoid函式),輸出的是機率,因此被用作分類任務的一部分。模型透過最大化對數似然函式來確定 $$W$$ 的取值,通常使用迭代最佳化演算法而非閉式解。

線性判別分析透過類間散度矩陣 $$S_b$$ 和類內散度矩陣 $$S_w$$ 擬合一個目標函式,使得 $$S_b$$ 和 $$S_w$$ 的廣義瑞利商最大,最後求得 $$W$$ 的閉式解($S_w ^{-1}S_b$_的N-1個最大廣義特徵值所對應的特徵向量組成的矩陣),。透過將輸出的值離散化為類別從而達到分類的目的。

決策樹的引入

在分類問題中,決策樹比線性模型生動得多。決策樹是一種劃分策略,它透過一系列二分判斷劃分資料空間,並生成樹狀結構,每個節點表示一次決策後的資料集,邊表示決策後資料集的分裂,葉子節點為最終輸出的類別標籤。決策樹的引入是自然的,符合人們認識事物的規律,當判斷一個瓜是好瓜還是壞瓜,我們根據西瓜的顏色瓜蒂等屬性進行一系列二分,最後形成一套僅透過輸入屬性以及屬性值就能判斷瓜好壞的“模型”。(屬性與特徵在這裡同義)

以下是一個簡單的西瓜資料集示例,包含一些離散屬性和屬性值:

色澤 根蒂 敲聲 紋理 臍部 觸感 好瓜
青綠 蜷縮 濁響 清晰 凹陷 硬滑
烏黑 蜷縮 沉悶 清晰 凹陷 硬滑
烏黑 蜷縮 濁響 清晰 凹陷 硬滑
青綠 蜷縮 沉悶 清晰 凹陷 硬滑
淺白 蜷縮 濁響 清晰 凹陷 硬滑
青綠 稍蜷 濁響 清晰 稍凹 軟粘
烏黑 稍蜷 濁響 稍糊 稍凹 軟粘
烏黑 稍蜷 濁響 清晰 稍凹 硬滑
烏黑 稍蜷 沉悶 稍糊 稍凹 硬滑
青綠 硬挺 清脆 清晰 平坦 軟粘
淺白 硬挺 清脆 模糊 平坦 硬滑
淺白 蜷縮 濁響 模糊 平坦 軟粘
青綠 蜷縮 濁響 稍糊 凹陷 硬滑
淺白 蜷縮 濁響 清晰 稍凹 硬滑
烏黑 稍蜷 沉悶 稍糊 稍凹 軟粘

這個表格展示了每個西瓜樣本的屬性及其對應的好瓜(是)或壞瓜(否)標籤。

決策樹構建

決策樹的構建可以這樣描述:

  • 選取最優特徵;
  • 分割資料集;
  • 在以下三種情況退出分割:
    • 當所有資料屬於同一類別;
    • 資料集的屬性為空或者屬性值全部相同;
    • 資料集無法繼續分割(樣本資料量小於我們設定的某個閾值)

選取策略

上面我們提到了決策樹的具體演算法,大部分都很好實現,只有"選取最優特徵"存在疑惑--什麼是最優特徵?

為了判斷什麼是最優特徵,我們需要引入一些量化指標進行評估。

資訊增益

色澤 根蒂 敲聲 紋理 臍部 觸感 好瓜
青綠 蜷縮 濁響 清晰 凹陷 硬滑
烏黑 蜷縮 沉悶 清晰 凹陷 硬滑
烏黑 蜷縮 濁響 清晰 凹陷 硬滑
青綠 蜷縮 沉悶 清晰 凹陷 硬滑
淺白 蜷縮 濁響 清晰 凹陷 硬滑
青綠 稍蜷 濁響 清晰 稍凹 軟粘
烏黑 稍蜷 濁響 稍糊 稍凹 軟粘
烏黑 稍蜷 濁響 清晰 稍凹 硬滑
烏黑 稍蜷 沉悶 稍糊 稍凹 硬滑
青綠 硬挺 清脆 清晰 平坦 軟粘
淺白 硬挺 清脆 模糊 平坦 硬滑
淺白 蜷縮 濁響 模糊 平坦 軟粘
青綠 蜷縮 濁響 稍糊 凹陷 硬滑
淺白 蜷縮 濁響 清晰 稍凹 硬滑
烏黑 稍蜷 沉悶 稍糊 稍凹 軟粘

資訊熵(Entropy)是用來度量樣本集合純度的指標,假設定當前樣本集合$D$中第$k$類樣本所佔的比例為 $P_k$ (k = 1, 2,. . . , $|Y|$),在這裡我們可以將其表示為:
$$
Entr(D)=-\sum_{k=1}^{|Y|}p_klog_2p_k\ .
$$
其中Ent(D)的值越小,則D越純,這也很符合直覺,因為熵越大表明混亂程度越高,攜帶資訊越多,而分類目的就是減少混亂程度。

因此我們可以利用熵來表示當前資料集合的純度,假設離散屬性$a$有$V$個可能的取值{${a1,a2,a3,a4...}$},當根據屬性$a$進行劃分會產生$V$個分支節點,第$v$個節點包含$D$中所有屬性$a$為$av$的資料集,記作$Dv$,我們為所有劃分後的子集分配權重,並與進行劃分前的資訊熵作差就得到了我們第一個參考指標,資訊增益(Gain):
$$
Gain(D,a) = Ent(D) -\sum_{k=1}{|Y|}\frac{|Dv|}{|D|}Entr(D^v) \ .
$$
這裡給出規律:

  • Gain越大表明使用$a$進行屬性劃分獲得的純度增益越大

從而我們有了第一個評估指標。

增益率

編號 色澤 根蒂 敲聲 紋理 臍部 觸感 好瓜
1 青綠 蜷縮 濁響 清晰 凹陷 硬滑
2 烏黑 蜷縮 沉悶 清晰 凹陷 硬滑
3 烏黑 蜷縮 濁響 清晰 凹陷 硬滑
4 青綠 蜷縮 沉悶 清晰 凹陷 硬滑
5 淺白 蜷縮 濁響 清晰 凹陷 硬滑
6 青綠 稍蜷 濁響 清晰 稍凹 軟粘
7 烏黑 稍蜷 濁響 稍糊 稍凹 軟粘
8 烏黑 稍蜷 濁響 清晰 稍凹 硬滑
9 烏黑 稍蜷 沉悶 稍糊 稍凹 硬滑
10 青綠 硬挺 清脆 清晰 平坦 軟粘
11 淺白 硬挺 清脆 模糊 平坦 硬滑
12 淺白 蜷縮 濁響 模糊 平坦 軟粘
13 青綠 蜷縮 濁響 稍糊 凹陷 硬滑
14 淺白 蜷縮 濁響 清晰 稍凹 硬滑
15 烏黑 稍蜷 沉悶 稍糊 稍凹 軟粘

思考這樣一個問題,假設編號也是一個屬性,我們將編號作為劃分依據,這樣的結果如何呢?

從結果上不難想到,這樣劃分得到的資料集只包含一個資料樣例,因此這樣劃分的純度最高,但根據經驗判斷,編號本身對西瓜好壞程度是沒有關聯的,因此選取編號作為劃分依據是錯誤的做法,為了規避資訊增益對可取數目較多的屬性有所偏好,於是引入增益率(Gain Ratio):
$$
Gain_ratio(D,a) = \frac{Gain(D,a)}{IV(a)},\
IV(a) = -\sum_{v=1}{V}\frac{|Dv|}{|D|}log_2\frac{|D^v|}{|D|}
$$
$IV(a)$稱為屬性$a$的固有值(instrinic value),可以看到當屬性$a$可能的取值越多,$IV(a)$越大,有效規避資訊增益對可取數目較多的屬性有所偏好。

增益率在C4.5決策樹作為擇優標準,增益率越大,劃分效果越好。

基尼指數

假設定當前樣本集合$D$中第$k$類樣本所佔的比例為 $p_k$ (k = 1, 2,. . . , $|Y|$),定義基尼值:
$$
Gini(D)=1-\sum_{k=1}{|Y|}p_k2\ .
$$
前面我們透過定義資料的純度引入資訊熵,透過資訊熵構造的資訊增益和增益率來作為判斷純度增加的依據,在這裡,我們不使用資訊熵,反而透過描述一個資料集之間隨機抽取兩個樣本,透過計算兩個樣本的標籤類別不一致的機率來作為判斷純度的依據,因此基尼值的引入也很自然

所以,假設離散屬性$a$有$V$個可能的取值{${a1,a2,a3,a4...}$},當根據屬性$a$進行劃分會產生$V$個分支節點,第$v$個節點包含$D$中所有屬性$a$為$av$的資料集,記作$Dv$,我們為所有劃分後的子集分配權重,就得到了基尼指數:
$$
Gini_index(D,a) = \sum_{v=1}V\frac{|Dv|}{|D|}Gini(D^v)\ .
$$
$ Gini(D)$ 越小,則資料集$D$的純度越高.

決策樹的構建

將上述資料集合抽象為特徵矩陣$X$和標籤向量$y$,特徵矩陣將使用數字將特徵值編號,列代表特徵,標籤向量為特徵矩陣每一行對應一個分類標籤,類似如下:

X = np.array([
    [0, 0, 0, 0, 0, 0],  # 青綠, 蜷縮, 濁響, 清晰, 凹陷, 硬滑
    [1, 0, 0, 0, 0, 0],  # 烏黑, 蜷縮, 濁響, 清晰, 凹陷, 硬滑
    [2, 0, 0, 0, 0, 0],  # 淺白, 蜷縮, 濁響, 清晰, 凹陷, 硬滑
    [0, 1, 1, 0, 1, 1],  # 青綠, 稍蜷, 沉悶, 清晰, 稍凹, 軟粘
    [1, 1, 1, 1, 1, 1],  # 烏黑, 稍蜷, 沉悶, 稍糊, 稍凹, 軟粘
    [2, 1, 1, 1, 1, 0],  # 淺白, 稍蜷, 沉悶, 稍糊, 稍凹, 硬滑
    [0, 2, 2, 2, 2, 1],  # 青綠, 硬挺, 清脆, 模糊, 平坦, 軟粘
    [1, 2, 2, 1, 2, 0],  # 烏黑, 硬挺, 清脆, 稍糊, 平坦, 硬滑
    [2, 2, 2, 2, 2, 1]   # 淺白, 硬挺, 清脆, 模糊, 平坦, 軟粘
])

# 標籤向量 y
y = np.array([1, 1, 1, 0, 0, 0, 0, 0, 0])  # 1: 好瓜, 0: 壞瓜

接下來直接給出程式碼

class Node:
    def __init__(self, feature_index = None, feature_val=None, left=None, right=None,value=None):
        self.feature_index = feature_index
        self.feature_val = feature_val
        self.left = left
        self.right = right
        self.value = value



class DecisionTree(object):
    def __init__(self, criterion = 'gini', max_deepth = None, min_sample_split = None, root = None):
        self.root = root
        self.criterion = criterion
        self.max_deepth = max_deepth
        self.min_sample_split = 2

    def _caculate_gini(self, X, y, feature_index, feature_val):
        '''
        計算基尼指數,
        基尼值:計算所有標籤的1-p_k^2之和,
        基尼指數:在每個屬性(feature_index)下根據屬性值(feature_val)分配權重計算基尼值之和
        '''
        left = X[:, feature_index] <= feature_val
        right = X[:, feature_index] > feature_val
        y_left, y_right = y[left], y[right]
        # 計算基尼值
        def gini(y_subset):
           classes, counts = np.unique(y_subset, return_counts = True)
           p_k = counts / len(y_subset)
           gini_val = 1 - sum(p_k** 2)
           return gini_val
        
        left_gini = gini(y_left)
        right_gini = gini(y_right)

        #計算加權值
        total_gini = (len(y_left)/len(y))*left_gini+(len(y_right)/len(y))*right_gini

        return total_gini
    
    def _split_node(self, X, y, criterion = 'gini'):
        '''
        將X進行劃分,根據gini指數等,返回最佳劃分方案,
        為一個包含gini指數,最佳劃分屬性編號和最佳劃分屬性值的的三元組
        '''
        best_criterion = float('inf') if criterion == 'gini' else -float('inf')
        best_feature_index = None
        best_feature_val = None

        _,n_features = X.shape

        for feature_index in range(n_features):
            feature_vals = np.unique(X[:,feature_index])
            # 如果判斷其是gini指數
            for feature_val in feature_vals:
                if criterion == 'gini':
                    gini = self._caculate_gini(X, y, feature_index, feature_val)
                    
                    # 更新最優劃分
                    if gini < best_criterion:
                        best_criterion = gini
                        best_feature_index = feature_index
                        best_feature_val = feature_val
                # 如果判斷器是gain增益率
                elif criterion == 'gain':
                    gain = self._caculate_gain(X, y, feature_index, feature_val)
                    
                    # 更新最優劃分
                    if gain > best_criterion:
                        best_criterion = gain
                        best_feature_index = feature_index
                        best_feature_val = feature_val

        return best_criterion, best_feature_index,best_feature_val
    

    
    def _most_common_label(self, y):
        return np.bincount(y).argmax()
    
    def _build_tree(self, X, y, depth = 0, pred = 0):
        '''
        構建樹,傳入特徵向量X和標籤向量y
        
        '''
        # 首先設定停止劃分的條件,葉子節點儲存輸出類別,類別為當前標籤集合中最常見的標籤
        n_samples, n_features = X.shape# 行數正好是資料總數,列數為屬性的總數目
        n_labels = len(np.unique(y))

        if n_labels == 1 or depth >= self.max_deepth or n_samples < self.min_sample_split:
            leaf_val = self._most_common_label(y)
            return Node(value=leaf_val)
        
        # 開始劃分
        _, feature_index, feature_val = self._split_node(X, y, 'gini')


        # 設定劃分的開始編號
        left_idx = X[:,feature_index] <= feature_val
        right_idx = X[:,feature_index] > feature_val

        left_pred = np.sum(y[left_idx] == 0) / len(y[left_idx])
        right_pred = np.sum(y[right_idx] == 0) / len(y[right_idx])

        # 預剪枝,遞迴處理左右子集
        if pred >= left_pred and pred >= right_pred:
            leaf_val = self._most_common_label(y)
            return Node(value=leaf_val)
        else:
            left = self._build_tree(X[left_idx,:],y[left_idx], depth+1, left_pred)
            right = self._build_tree(X[right_idx,:], y[right_idx],depth+1, right_pred)

            if left is None and right is None:
                leaf_val = self._most_common_label(y)
                return Node(value=leaf_val)

        # 返回樹結構
        return Node(feature_index=feature_index,feature_val=feature_val,left=left, right=right)
    

    def _traverse_tree(self, x, node):
        '''
        遍歷樹以匹配輸入資料x的輸出標籤類別
        '''

        if node.value is not None:
            return node.value
        
        if x[node.feature_index] <= node.feature_val:
            return self._traverse_tree(x, node.left)
        return self._traverse_tree(x, node.right)
    
    def fit(self, X, y):
        self.root = self._build_tree(X,y) #將樹儲存再在oot中
    
    def predict(self, X):
        return [self._traverse_tree(x, self.root) for x in X]  

剪枝

預剪枝

預剪枝發生在劃分子集時,如果劃分子集之和,精度(這裡指子集中正類的佔比)沒有提升,那麼這次劃分就是需要捨去的


    	left_pred = np.sum(y[left_idx] == 0) / len(y[left_idx])
        right_pred = np.sum(y[right_idx] == 0) / len(y[right_idx])
    	# 預剪枝,遞迴處理左右子集
        if pred >= left_pred and pred >= right_pred:
            leaf_val = self._most_common_label(y)
            return Node(value=leaf_val)
        else:
            left = self._build_tree(X[left_idx,:],y[left_idx], depth+1, left_pred)
            right = self._build_tree(X[right_idx,:], y[right_idx],depth+1, right_pred)

            if left is None and right is None:
                leaf_val = self._most_common_label(y)
                return Node(value=leaf_val)

後剪枝

後剪枝先從訓練集生成一棵完整決策樹,計算決策樹的精度,然後自底向上地將節點替換為葉子節點,如果精度上升,則保留葉子節點,如果下降,則撤回操作,繼續對其他節點進行操作。

    # 後剪枝
    def _prune_tree(self, node, X_val, y_val):
        if node is None:
            return None
        
        # 遞迴地剪枝左子樹和右子樹
        node.left = self._prune_tree(node.left, X_val, y_val)
        node.right = self._prune_tree(node.right, X_val, y_val)
        
        # 如果當前節點的左右子樹都為空,返回當前節點
        if node.left is None and node.right is None:
            return node
        
        # 獲取當前節點的值
        new_leaf_val = node.value
        
        # 如果左右子樹都存在,則嘗試剪枝
        if node.left is not None and node.right is not None:
            # 儲存原始樹
            original_tree = self
            
            # 建立一個新的葉子節點,其值為當前節點的值
            new_node = Node(value=new_leaf_val)
            self.root = new_node
            
            # 計算剪枝前後模型在驗證集上的準確度
            if self._evaluate_tree(X_val, y_val) > self._evaluate_tree(original_tree, X_val, y_val):
                # 如果剪枝後的樹在驗證集上的表現更好,返回新的葉子節點
                return new_node
            else:
                # 否則,恢復原始樹並返回原始節點
                self.root = node
        
        return node

在寫訓練函式fit時作出適當改動:

    def fit(self, X, y, X_val = None, y_val = None, prune = False):
        self.root = self._build_tree(X,y) #將樹儲存再在root中
        if prune and X_val is not None and y_val is not None:
            self._prune_tree(self.root, X_val, y_val)

相關文章