机器学习:决策树过拟合与剪枝,决策树代码实现(三)

楔子

上次讲到:至此node类的变量和方法基本实现完毕,为什么说基本呢,因为真正的后剪枝还没讲,他还需要在node类里添加一些方法。这一次来讲一下后剪枝。

首先,后剪枝是对整个生成树操作,我们给整个树的操作定义一个基类,定义一个新类就涉及到:变量和方法

Tree 结构需要做到如下几点:

定义好需要在各个 Node 上调用的“全局变量”
做好数据预处理的工作、保证传给 Node 的数据是合乎要求的
对各个 Node 进行合适的封装,做到:
	生成决策树时能够正确地调用它们的生成算法
	进行后剪枝时能够正确地调用它们的局部剪枝函数
定义预测函数和评估函数以供用户调用

变量

首先,我们思考一下,我们整体考虑生成树,并对树进行操作,我们需要操作哪些对象:

1、我们需要剪枝,就需要对结点操作,在这里我们不好每次都遍历树一遍,我们把所有的node存下来专门处理,self.nodes = []
2、每个node都有一个可选features的列表,但是选中某个feature之后,遍历featureValue时,在node里面没有变量定义,在全局变量里面定义一个,所有features的featureValue的变量:self.feature_sets;同样的道理各个特征的维度是否连续也是如此:self.whether_continuous
3、剪枝属于全局的操作,变量也应该是全局的:限制树的深度:self.max_depth;CART种需要处理p颗生成树:self.roots
4、还有一个最终要的变量,就是树的根:self.root

from copy import deepcopy
from Node import *
import numpy as np
class CvBase:
    def __init__(self,max_depth= None, node= None):
        # self.nodes:记录所有node的列表
        self.nodes = []
        # self.roots:主要用于CART属性,存储算法过程中的各个决策树
        self.roots = []
        # self.max_depth:用于记录决策树的最大深度
        self.max_depth = max_depth
        # self.root: 根节点
        self.root = node
        # self.feature_sets:用于记录可选特征维度的列表
        self.feature_sets = []
        # self.label_dic:类别的转换字典
        self.label_dic = {}
        # self.prune_alpha,self.layers:ID3和C4.5剪枝的两个属性
        self.prune_alpha = 1
        # 前者是惩罚因子,后者是记录每一层的node
        # self.whether_continuous:记录各维度特征是否是连续的
        self.whether_continuous = None
    
    def __str__(self):
        return "CvTree ({})".format(self.root.height)
    
    __repr__ = __str__

方法

数据预处理

自动判断哪些features为连续的;初始化树的全局变量

    def feed_data(self, x, continuous_rate = 0.2):
        # continuous_rate用于判断该维度是否是连续的
        # 利用set获取各个维度的特征可能取值
        self.feature_sets = [set(dimension) for dimension in x.T]
        data_len, data_dim = x.shape
        # 判断是否连续
        self.whether_continuous = np.array(
                [len(feat) >= continuous_rate*data_len for feat in self.feature_sets])
        # 根节点可选的划分特征维度
        self.root.feats = [i for i in range(x.shape[1])]
        # 把
        self.root.feed_tree(self)

最后一行我们对根节点调用了feed_tree方法,该方法会做以下三件事:

让决策树中所有的 Node 记录一下它们所属的 Tree 结构
将自己记录在 Tree 中记录所有 Node 的列表nodes里
根据 Tree 的相应属性更新记录连续特征的列表
    # 栽树,会做三件事
    # 决策树所有node记录他们属于哪一颗树
    # 把所有结点保存到self.tree.nodes
    # 更新每一个结点的特征是否连续的列表
    def feed_tree(self, tree):
        self.tree = tree
        self.tree.nodes.append(self)
        self.wc = tree.whether_continuous
        for child in self.children.values():
            if child is not None:
                child.feed_tree(tree)

剪枝

剪枝时,需要获取所有的非叶子结点,为待剪集,从底层像高层一层一层的剪枝。

获取待剪集:

# =============================================================================
# # 定义Prune
# 因为是后剪枝是针对全局的考虑,要决定那些结点需要剪枝,然后再调用结点的剪枝
# =============================================================================
    # 获取每一层的结点self.layers:[depth,node_lst] = node
    def _update_layers(self):
        self.layers = [[] for _ in range(self.root.height)]
        self.root.update_layers()

# Util
    # 获取以当前结点为根的树的每一层结点列表
    def update_layers(self):
        self.tree.layers[self._depth].append(self)
        for node in sorted(self.children):
            node = self.children[node]
            if node is not None:
                node.update_layers()

针对ID3,C4.5的剪枝

损失函数的设计

在这里插入图片描述

    # 新的损失函数,当未剪枝时损失,已剪枝或者叶子的损失
    def cost(self, pruned=False):
        if not pruned:
            return sum([leaf["chaos"] * len(leaf["y"]) for leaf in self.leafs.values()])
        return self.chaos * len(self._y)

# node.cost() + self.prune_alpha * len(node.leafs)

基于该损失函数的算法描述

在这里插入图片描述

基于该损失函数的代码实现

    # 离散数据的剪枝函数
    def _prune(self):
        # 获取生成树每一层的结点,每一层结点按照其划分feature顺序排列
        self._update_layers()
        # 用于保存所有的非叶子结点,为待剪枝结点,保存顺序前面的靠近底部,后面的靠近根部
        tmp_nodes = []
        append = tmp_nodes.append
        for node_lst in self.layers[::-1]:
            for node in node_lst[::-1]:
                if node.category is None:
                    append(node)
        # 剪枝的新损失函数 = 各个叶子不确定度*叶子样本数量加权和 + alpha*叶子个数
        # old为剪枝前的损失函数,所有的待剪枝结点的剪枝前的损失函数
        old = np.array([node.cost() + self.prune_alpha * len(node.leafs) for node in tmp_nodes])
        # 假如进行剪枝后,当前结点变成叶子,损失函数 = 当前结点的不确定度*样本个数 + alpha*1
        new = np.array([node.cost(pruned=True) + self.prune_alpha for node in tmp_nodes])
        # 根据这个得到待剪枝的结点mask
        mask = old >= new
        while True:
            # 剪到根时退出
            if self.root.height == 1:
                break
            # 获取最深的待剪枝的结点,从下往上的剪枝,取的是第一个True,前面都是靠近底部的结点
            p = np.argmax(mask)  # type: int
            # 判断一下是否是可剪枝的,每次剪枝之后,会影响上层的结点,可能Ture变成了False,
            # 最后一次时里面,里面可能全部都是False           
            if mask[p]:
                # 对这个结点剪枝,该做的操作在结点里面都操作了,里面还有一项操作
                # 就是剪枝该结点,会对那些结点有影响,就是他的祖宗们,已标记node.affecte
                tmp_nodes[p].prune()
                # 遍历所有的待剪枝结点,挑出被当前结点影响的结点
                for i, node in enumerate(tmp_nodes):
                    if node.affected:
                        # 更新那些结点的损失函数
                        old[i] = node.cost() + self.prune_alpha * len(node.leafs)
                        # 再次判断是否需要被剪枝,new是不会变的他只和样本有关
                        mask[i] = old[i] >= new[i]
                        # 重置一下,以免下次也更新他了
                        node.affected = False
                # 把标记为已剪枝的结点从待剪枝结点列表删除,当前结点也是标记为已剪枝的
                # 他已经变成叶子结点,叶子结点是不在待剪枝列表的
                for i in range(len(tmp_nodes) - 1, -1, -1):
                    if tmp_nodes[i].pruned:
                        tmp_nodes.pop(i)
                        old = np.delete(old, i)
                        new = np.delete(new, i)
                        mask = np.delete(mask, i)
            # 假如待剪枝列表没有可剪枝的也退出
            else:
                break
        # 剪枝完毕之后,新的生成树,更新一下,这棵树的nodes列表,把前面删除的叶子都删除掉
        # 前后的剪枝函数主要处理的是leafs,没有处理nodes,所以最后处理一下。
        self.reduce_nodes()

针对CART的剪枝

损失函数的设计

这个的设计思想是,随着惩罚因子alpha从0到大不断增加,结点被一个一个剪掉,每剪掉一颗都是一棵树保存起来,最后只剩下root,形成了p棵树,求p棵树里面的最优树。
每一个结点都有一个alpha的阈值,超过了这个阈值,该节点就可以被剪掉。
阈值的实现:

    # 获取该节点的阈值,就是惩罚因子有多大时,就轮到这个结点被剪掉了,
    # 当然这个可能会随着一些结点被剪掉而变化,
    # 随着惩罚因子的变大,结点会一个一个剪掉,知道只剩下根
    def get_threshold(self):
        return (self.cost(pruned=True) - self.cost()) / (len(self.leafs) - 1)
    # 说初始化整颗树的self.tree值,这棵树的每个结点属于哪棵树

基于该损失函数的算法描述

在这里插入图片描述

基于该损失函数的代码实现

获得p颗生成树
    # CART的剪枝处理
    def _cart_prune(self):
        # 初始化整颗树的self.tree值,这棵树的每个结点属于哪棵树
        self.root.cut_tree()
        # 获取待剪枝的结点列表,也就是非叶子结点
        tmp_nodes = [node for node in self.nodes if node.category is None]
        # 计算这些候选集的阈值
        thresholds = np.array([node.get_threshold() for node in tmp_nodes])
        while True:
            # 理论上我们需要记录p棵树,然后在p颗树里找最好的那棵树,
            # 因此我们需要深度copy原始树,在此基本上剪枝,每次形成不同的树
            root_copy = deepcopy(self.root)
            # self.roots用于记录产生的p棵树,先把原始树存进来
            self.roots.append(root_copy)
            # 出口,只剩根结点了,p棵树产生完毕
            if self.root.height == 1:
                break
            # 取阈值最低的结点,那个结点第一个被剪
            p = np.argmin(thresholds)  # type: int
            # 下面的处理和离散处理一致
            tmp_nodes[p].prune()
            # 剪掉之后,看哪些结点受影响了,更新受影响的结点
            for i, node in enumerate(tmp_nodes):
                if node.affected:
                    # 对于受影响的结点,更新一下阈值
                    thresholds[i] = node.get_threshold()
                    node.affected = False
            pop = tmp_nodes.pop
            for i in range(len(tmp_nodes) - 1, -1, -1):
                if tmp_nodes[i].pruned:
                    pop(i)
                    thresholds = np.delete(thresholds, i)
        self.reduce_nodes()
选取最优生成树
    # 定义选择那个树最优的标准,使用加权正确率作为交叉验证的标准
    def acc(self, y, y_pred, weights):
        if weights is not None:
            return np.sum((np.array(y) == np.array(y_pred))*weights) /len(y)
        return np.sum(np.array(y) == np.array(y_pred)) /len(y)
    # 后剪枝是通过比较每棵树在验证集上的表现来找出最优树
    def prune(self, x_cv, y_cv, weights):
        if self.root.is_cart:
            if x_cv is not None and y_cv is not None:
                self._cart_prune()
                # 选出最优的子树
                arg = np.argmax([self.acc(y_cv, tree.predict(x_cv), weights) for tree in self.roots])  # type: int
                tar_root = self.roots[arg]
                self.nodes = []
                # 更新一下树的相关信息,所属tree,所有的nodes
                tar_root.feed_tree(self)
                # 把指针给root
                self.root = tar_root
        else:
            self._prune()   

整个流程处理fit():

方法都有了下面就开始整个操作流程:准备数据,数据预处理,生成树,剪枝

# =============================================================================
    # 参数alpha和剪枝有关;cv_rate用于控制交叉验证集大小;train_only是否进行数据集切分
    def fit(self,x,y,alpha= None, sample_weight= None, eps= 12-8, cv_rate= 0.2, train_only= False):
        # 数值化类别向量
        _dic = {c:i for i,c in enumerate(set(y))}
        # 将y数值化
        y = np.array([_dic[yy] for yy in y])
        # 保存ID-->class映射,这样才可以反向找回去
        self.label_dic = {value:key for key,value in _dic.items()}
        # 如果x为非数值的,也需要数值化
        x = np.array(x)
        # 根据特征个数给出alpha
        self.prune_alpha = alpha if alpha is not None else x.shape[1]/2
        
        # 划分数据集
        if not train_only and self.root.is_cart:
            # 利用下标实现各种切分
            _train_num = int(len(x)*(1-cv_rate))
            # 相当于打乱了顺序
            _indices = np.random.permutation(np.arange(len(x)))
            _train_indices = _indices[:_train_num]
            _test_indices = _indices[_train_num:]
            
            # 针对样本权重的处理
            if sample_weight is not None:
                # 切分后的样本权重需要做归一化处理
                _train_weight = sample_weight[_train_indices]
                _test_weight = sample_weight[_test_indices]
                # 归一化
                _train_weight /= np.sum(_train_weight)
                _test_weight /= np.sum(_test_weight)
            else:
                _train_weight = _test_weight = None
            
            x_train, y_train = x[_train_indices],y[_train_indices]
            x_cv, y_cv = x[_test_indices],y[_test_indices]
            
        else:
            x_train, y_train, _train_weight = x, y, sample_weight
            x_cv = y_cv = _test_weight = None
        # 数据预处理   
        self.feed_data(x_train)
        # 调用根节点的生成算法
        self.root.fit(x_train, y_train, _train_weight, eps)
        # 调用对node的剪枝算法的封装
        self.prune(x_cv, y_cv, _test_weight)
    
    # 定义删除结点方法,从后往前删除,这样就可以使用pop
    def reduce_nodes(self):
        for i in range(len(self.nodes)-1, -1, -1):
            if self.nodes[i].pruned:
                self.nodes.pop(i)        

猜你喜欢

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