机器学习:结点的实现,决策树代码实现(二)

楔子

前面已经实现了各种信息量的计算,那么我们划分的基本有了,那么我们需要使用这个基本来划分,来生成决策树,树的基本单元是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类里添加一些方法。

猜你喜欢

转载自blog.csdn.net/weixin_40759186/article/details/85375561