1. 程式人生 > >機器學習:結點的實現,決策樹程式碼實現(二)

機器學習:結點的實現,決策樹程式碼實現(二)

文章目錄

楔子

前面已經實現了各種資訊量的計算,那麼我們劃分的基本有了,那麼我們需要使用這個基本來劃分,來生成決策樹,樹的基本單元是node,這裡的node是一堆資料的集合+他們內在的方法。由於需要處理三種演算法,我們最好能使用基類,該類應該至少包含:

1、選擇劃分的feature;
2、根據基準劃分資料生成新的結點;
3、判斷那些節點可以當成葉子,並歸類;

定義變數:

node的變數是不是有點多,容易糊塗,我們需要抓住重點,首先是一個node,那麼這個node有那些變量了,按照上述方法應該有如下三個變數:self.feature_dim(劃分的feature),self._children(生成的子節點),self.leafs(一顆子節點實際代表一顆子樹,這顆子樹下的所有葉子結點),當然還有資料集(self._x ,self._y),其他的變數根據需要慢慢新增。

# =============================================================================
# 實現完資訊量的計算,下面就考慮決策樹的生成
# 決策樹是一顆x叉樹,需要資料結構node # 我們需要同時處理ID3,C4.5,CART所以需要建立一個基類 # 類的方法:1、根據離散特徵劃分資料;2、根據連續特徵劃分資料;3、根據當前資料判斷 # 屬於哪一個類別 # ============================================================================= import numpy as np from Cluster import Cluster class CvDNode: def __init__(self,tree=None,base=2,chaos=None
,depth=0,parent=None,is_root=True,pre_feat="Root"): # 資料集的變數,都是numpy陣列 self._x = self._y = None # 記錄當前的log底和當前的不確定度 self.base,self.chaos =base,chaos # 計算該節點的資訊增益的方法 self.criterion = None # 該節點所屬的類別 self.category = None # 針對連續特徵和CART,記錄當前結點的左右結點 self.left_child = self.right_child =None # 該node的所有子節點和所有的葉子結點 self._children,self.leafs = {},{} # 記錄樣本權重 self.sample_weight =None # whether continuous記錄各個緯度的特徵是否連續 self.wc = None # 記錄該node為根的tree self.tree =tree # 如果傳入tree的話,初始化 if tree is not None: # 資料預處理是由tree完成 # 各個features是否連續也是由tree記錄 self.wc = tree.whether_continuous # 這裡的node變數是Tree中所記錄的所有node的列表 tree.nodes.append(self) # 記錄該node劃分的相關資訊: # 記錄劃分選取的feature self.feature_dim =None # 記錄劃分二分劃分的標準,針對連續特徵和CART self.tar = None # 記錄劃分標準的feature的featureValues self.feats =[] # 記錄該結點的父節點和是否為根結點 self.parent =parent self.is_root = is_root # 記錄結點的深度 self._depth = depth # 記錄該節點的父節點的劃分標準feature self.pre_feat = pre_feat # 記錄該節點是否使用的CART演算法 self.is_cart =False # 記錄該node劃分標準feature是否是連續的 self.is_continuous = False # 記錄該node是否已經被剪掉,後面剪枝會用到 self.pruned = False # 用於剪枝時,刪除一個node對那些node有影響 self.affected = False # 過載__getitem__運算子,支援[]運算子 # 過載__getattribute__運算子,支援.運算子 def __getitem__(self,item): if isinstance(item,str): return getattr(self,"_" + item) # 過載__lt__的方法,使node之間可以相互比較,less than,方便除錯 def __lt__(self,other): return self.pre_feat < other.pre_feat # 過載__str__和 __repr__為了方便除錯 def __str__(self): # 該節點的類別屬性 if self.category is None: return "CvDNode ({}) ({} -> {})".format(self.depth,self.pre_feat,self.feature_dim) return "CvDNode ({}) ({} -> class:{})".format(self.depth,self.pre_feat,self.tree.label[self.category]) # __repr__ 用於列印,面對程式設計師,__str__用於列印,面對使用者 __repr__ = __str__ # ============================================================================= # 定義幾個property,Python內建的@property裝飾器就是負責把一個方法變成屬性呼叫的 # @property廣泛應用在類的定義中,可以讓呼叫者寫出簡短的程式碼, # 同時保證對引數進行必要的檢查,這樣,程式執行時就減少了出錯的可能性。 # ============================================================================= # 定義children屬性,主要區分開連續,CART和其餘情況 # 有了該屬性之後所有子節點都不需要區分情況了 @property def children(self): return { "left" : self.left_child, "right" : self.right_child } if (self.is_cart or self.is_continuous) else self._children # 遞迴定義高度屬性 # 葉子結點的高度為1,其餘結點高度為子節點最高高度+1 @property def height(self): if self.category is not None: return 1 return 1 + max([_child.height if _child is not None else 0 for _child in self.children.values()]) # 定義info_dic屬性(資訊字典),記錄了該node的主要屬性 # 在更新各個node的葉子結點時,葉子結點self.leafs的屬性就是該節點 @property def info_dic(self): return {"chaos" : self.chaos,"y" :self._y}

定義方法

大概回憶一下演算法的大概處理流程:選擇劃分的features,遞迴的劃分生成子節點,滿足停止條件,形成葉子。上述就是整個fit的過程。在實現主要函式fit()前,我們需要定義好一些fit()會使用到的函式。

獲得劃分的feature

這個放在fit()函式裡面,見fit()

生成結點

獲取了劃分的feature之後,就需要根據這標準來生成子樹,或者說子節點。
處理大概過程:我們需要生成一個新結點,需要知道他的資料樣本(通過mask找到),他對應的chaos,還有一些其他的輔助變數,形成新的結點或者子樹,然後遞迴處理這堆資料不斷的生成結點。這裡面需要區分離散、連續和CART3種情況處理。

# =============================================================================
#   生成結點的方法
# =============================================================================
    # chaos_lst:[featureValue] = chaos,指定feature下,不同featureValue的不確定度
    def _gen_children(self, chaos_lst):
        # 獲取當前結點的劃分feature,連續性時:該feature劃分基準為tar
        feat, tar = self.feature_dim, self.tar
        # 獲取該feature是否是連續的
        self.is_continuous = continuous = self.wc[feat]
        # 取對應feature的N個數據,這是簡化的寫法,得到該feature的那列,實際是一行
        features = self._x[..., feat]
        # 當前結點可以使用的features
        new_feats = self.feats.copy()
        # 連續性二叉處理
        if continuous:
            # 根據劃分依據tar得到一類的mask
            mask = features < tar
            # 這個就是分成兩類的mask
            masks = [mask, ~mask]
        else:
            if self.is_cart:
                # CART根據劃分依據tar得到一類的mask
                mask = features == tar
                # 分成兩類的mask
                masks = [mask, ~mask]
                # 把這個劃分tar從指定feature下featureValue裡移除
                self.tree.feature_sets[feat].discard(tar)
            else:
                # 離散型,沒有mask,直接使用featureValue數量生成子節點
                masks = None
        # 二分情況處理
        if self.is_cart or continuous:
            feats = [tar, "+"] if not continuous else ["{:6.4}-".format(tar), "{:6.4}+".format(tar)]
            for feat, side, chaos in zip(feats, ["left_child", "right_child"], chaos_lst):
                new_node = self.__class__(
                    self.tree, self.base, chaos=chaos,
                    depth=self._depth + 1, parent=self, is_root=False, prev_feat=feat)
                new_node.criterion = self.criterion
                setattr(self, side, new_node)
            for node, feat_mask in zip([self.left_child, self.right_child], masks):
                if self.sample_weight is None:
                    local_weights = None
                else:
                    local_weights = self.sample_weight[feat_mask]
                    local_weights /= np.sum(local_weights)
                tmp_data, tmp_labels = self._x[feat_mask, ...], self._y[feat_mask]
                if len(tmp_labels) == 0:
                    continue
                node.feats = new_feats
                node.fit(tmp_data, tmp_labels, local_weights)
        else:
            # 離散情況處理
            # 可選擇的features裡移除已選擇的feature,子節點就使用這些features尋找劃分
            new_feats.remove(self.feature_dim)
            # self.tree.feature_sets[self.feature_dim]:對應的是這個特徵的所有特徵值
            # chaos_lst:對應的是每個特徵值的不確定度
            for feat, chaos in zip(self.tree.feature_sets[self.feature_dim], chaos_lst):
                # 這個特徵值的mask
                feat_mask = features == feat
                # 根據這個mask,找到這個特徵值對應那些資料
                tmp_x = self._x[feat_mask, ...]
                # 如果這個特徵值沒有資料,就取下一個特徵值,相當於沒有必要生成新結點
                if len(tmp_x) == 0:
                    continue
                # 否則的話生成新結點,新結點四個引數比較重要:ent,feature_dim,children,leafs
                new_node = self.__class__(
                    tree=self.tree, base=self.base, chaos=chaos,
                    depth=self._depth + 1, parent=self, is_root=False, prev_feat=feat)
                # 新結點的可選維度就是上述的new_feats
                new_node.feats = new_feats
                # 更新當前結點的子節點集
                self.children[feat] = new_node
                if self.sample_weight is None:
                    local_weights = None
                else:
                    # 帶權重的處理
                    local_weights = self.sample_weight[feat_mask]
                    # 需要歸一化
                    local_weights /= np.sum(local_weights)
                # 遞迴的處理新結點,需要把分塊的資料傳入進來
                new_node.fit(tmp_x, self._y[feat_mask], local_weights)   

停止條件及其處理

什麼時候停止生成子樹?就是什麼時候我們形成葉子,兩種情況,見下述程式碼。形成葉子之後的處理:已經判斷這堆資料可以當作葉子了,我們需要幹什麼:一是這對資料屬於哪一類(少數服從多數),二是更新當前結點的列祖列組的self.leafs,告訴列祖列組我是你們正宗的leaf。停止條件及其處理就是回溯法裡面的限界函式,用於剪枝,實際上這個只是預剪枝。

# =============================================================================
#   定義生成演算法的準備工作:定義停止生成的準則,定義停止後該node的行為
# =============================================================================
    # 停止的第一種情況:當特徵緯度為0或者當前node的資料的不確定性小於閾值停止
    # 假如指定了樹的最大深度,那麼當node的深度太深時也停止
    # 滿足停止條件返回True,否則返回False
    def stop1(self,eps):
        if (self._x.shape[1] == 0 or (self.chaos is not None and self.chaos < eps)
            or (self.tree.max_depth is not None and self._depth >=self.tree.max_depth)
            ):
            # 呼叫停止的方法
            self._handle_terminate()
            return True
        
        return False
    
    # 當最大資訊增益小於閾值時停止
    def stop2(self, max_gain, eps):
        if max_gain <= eps:
            # 呼叫停止的方法
            self._handle_terminate()
            return True
        return False
    # 定義該node所屬類別的方法,假如特徵已經選完了,將此事樣本中最多的類,作為該節點的類
    def get_category(self):
        return np.argmax(np.bincount(self._y))
    
    # 定義剪枝停止的處理方法,核心思想是:將node結點轉換為葉子結點
    def _handle_terminate(self):
        # 首先先生成該node的所屬類別
        self.category = self.get_category()
        # 然後一路回溯,更新該節點的所有祖先的葉子結點資訊
        _parent =self.parent
        while _parent is not None:
            # id(self)獲取當前物件的記憶體地址
            _parent.leafs[id(self)] = self.info_dic
            _parent = _parent.parent
            

fit()

下面就到了重點,這個是整個node處理的核心函式,實現前面提到的三個方法的處理流程,每次新結點的處理都是呼叫這個函式來實現:傳入資料集,計算資訊量,得到劃分的feature,是否滿足停止條件,否生成子節點遞迴的處理。

    # 挑選出最佳劃分的方法,要注意二分和多分的情況
    def fit(self, x, y, sample_weight, eps= 1e-8):
            self._x = np.atleast_2d(x)
            self._y = np.array(y)
            
            # 如果滿足第一種停止條件,則退出函式體
            if self.stop1(eps):
                return
            
            # 使用該node的資料例項化Cluster類以便計算各種資訊量
            _cluster = Cluster(self._x, self._y, sample_weight, self.base)
            # 對於根節點,需要額外計算其資料的不確定性,第一次需要計算,
            # 其他時候已經傳入了chaos
            if self.is_root:
                if self.criterion == "gini":
                    self.chaos = _cluster.gini()
                else:
                    self.chaos = _cluster.ent()
            
            # 用於存最大增益
            _max_gain = 0
            # 用於存取最大增益的那個feature,不同的featureValue限制下的不確定度
            # 最後[featureValue] = 對應的不確定度
            _chaos_lst = []
            # 遍歷還能選擇的features
            for feat in self.feats:
                # 如果是該維度是連續的,或者使用CART,則需要計算二分標準的featureValue
                # 的取值集合
                if self.wc[feat]:
                    _samples = np.sort(self._x.T(feat))
                    # 連續型的featureValue的取值集合
                    _set = (_samples[:-1] + _samples[1:]) *0.5
                else:
                    _set = self.tree.feature_sets[feat]
            
            # 連續型和CART,需要使用二分類的計算資訊量
                if self.wc[feat] or self.is_cart:
                    # 取一個featureValue
                    for tar in _set:
                        _tmp_gain, _tmp_chaos_lst = _cluster.bin_info_gain(
                                feat, tar,  criterion=self.criterion,
                                get_chaos_lst= True, continuous=self.wc[feat])
                        
                        if _tmp_gain > _max_gain:
                            (_max_gain,_chaos_lst),_max_feature,_max_tar =(
                                    _tmp_gain, _tmp_chaos_lst), feat, tar
                # 離散資料使用一般的處理                   
                else:
                    _tmp_gain, _tmp_chaos_lst = _cluster.info_gain(
                            feat, self.criterion, True, self.tree.feature_sets[feat])
                    if _tmp_gain > _max_gain:
                        (_max_gain,_chaos_lst),_max_feature =(
                                _tmp_gain, _tmp_chaos_lst), feat
            # 當所有features裡面最大的不確定都足夠小,退出函式體                    
            if self.stop2(_max_gain, eps):
                return
            
            # 更新相關的屬性
            # 當前結點劃分的feature
            self.feature_dim = _max_feature
            
            # 二叉的處理
            if self.is_cart or self.wc[_max_feature]:
                self.tar = _max_tar
                # 根據劃分進行生成結點
                self._gen_children(_chaos_lst)
                
                # 這個是專門針對二叉的處理,是在生成樹到底之後回溯時
                # 生成樹同一層兩個結點都生成的話,看能不能剪枝
                # 實際這也是後剪枝的一種,但是二叉樹比較好處理,剪枝的策略也比較簡單
                # 只是簡單的去重,所以x叉樹沒有實施,後剪枝有更加高效的策略
                # 如果左右結點都是葉子結點且他們都屬於同一個分類,可以使用剪枝操作
                if (self.left_child.category is not None and 
                    self.left_child.category == self.right_child.category):
                    self.prune()
                    # 呼叫tree的方法,剪掉該節點的左右子樹
                    # 從Tree的記錄所有Node的列表nodes中除去
                    self.tree.reduce_nodes()
                
            else:
                # 離散情況的處理
                self._gen_children(_chaos_lst)
                # 上述的剪枝策略,只是同一父親的子結點去重,x叉樹不好實現。
                # 直接採用更加高效的策略後剪枝

生成樹剪枝

得到一顆生成樹之後,這棵樹沒啥約束或者僅僅只靠資訊增益約束,可能枝繁葉茂,為了使這棵樹在其他資料集上能取得較好的泛化效能,我們需要剪枝,刪除一些葉子結點。
在樹生成完畢或者區域性生成完畢的剪枝稱之為後剪枝,上述fit()實現針對二叉樹的處理,左右結點生成完畢判斷兩個結點是否屬於同一類別剪枝,就是後剪枝的一種,實際後剪枝有一套全域性規劃的策略,下次再講。
post-prune的實現:這種情況的剪枝生成樹已經生成完畢,剪枝相當於把下面所有的葉子看成一個葉子結點,那麼需要把下面所有的葉子從列祖列宗的leafs譜裡除名,然後把當前結點成了葉子加入到列祖列宗的leafs譜裡,完了還需要標記自己和下面的結點為pruned已被剪掉,為什麼自己也需要剪掉,因為葉子的資訊已經在列祖列宗的leafs譜裡。

    # 剪枝操作,將當前結點轉化為葉子結點,這裡可能覺得莫名奇妙,前面停止處理的時候不是
    # 已經剪枝了嗎,這個地方怎麼還有專門的剪枝函式,而且還要把,把刪除剪枝剪掉的葉子。
    # 實際上這是後剪枝,稱之為post-pruning, stop裡面的剪枝稱之為pre-pruning.
    def prune(self):
        # 呼叫方法計算該node屬於的類別
        self.category = self.get_category()
        # 當前結點轉化為葉子結點,記錄其下屬結點的葉子結點
        _pop_lst = [key for key in self.leafs]
        # 然後一路回溯,更新各個parent的屬性葉子
        _parent = self.parent
        while _parent is not None: 
            # 刪除由於被剪枝而剪掉的葉子結點
            # 由於刪除結點,對父節點產生了影響,用於後剪枝更新新損失函式
            _parent.affected = True
            
            for _k in _pop_lst:
                _parent.leafs.pop(_k)
            # 把當前結點更新進去,因為當前結點變成了葉子
            _parent.leafs[id(self)] = self.info_dic
            _parent = _parent.parent
        
        # 呼叫mark_pruned函式將所有的子子孫孫標記為已剪掉,pruned屬性為True
        self.mark_pruned()
        # 重置各個屬性,這個有必要嗎,理論上是要刪除,畢竟現在是葉子了,葉子這些都
        # 應該是空的
        self.feature_dim = None
        self.left_child = self.right_child = None
        self._children = {}
        self.leafs = {}
        
    # 下面實現mark_pruned函式,self._children放的是子樹
    # 為啥把自己也置為True,自己也被剪掉了?還是因為葉子結點資訊已在父親的leafs裡不重要?
    def mark_pruned(self):
        self.pruned = True
        # 這裡使用的children屬性,獲取的是子樹,遞迴的呼叫
        for _child in self.children.value():
            if _child is  not None:
                _child.mark_pruned()

至此node類的變數和方法基本實現完畢,為什麼說基本呢,因為真正的後剪枝還沒將,他還需要在node類裡新增一些方法。