文章目录
楔子
上次讲到:至此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)