CART(Classification And Regression Trees,分类回归树)算法是一种树构建算法,既可以用于分类任务,又可以用于回归。相比于 ID3 和 C4.5 只能用于离散型数据且只能用于分类任务,CART 算法的适用面要广得多,既可用于离散型数据,又可以处理连续型数据,并且分类和回归任务都能处理。
CART 算法生成的决策树模型是二叉树,而 ID3 以及 C4.5 算法生成的决策树是多叉树,从运行效率角度考虑,二叉树模型会比多叉树运算效率高。
特征选择
在分类任务中 CART 算法使用基尼系数作为特征选择的依据,在回归任务中则以均方误差作为特征选择的依据。先来讲讲作为分类任务特征选择依据的基尼系数。
基尼系数
基尼系数代表模型的不纯度(混乱度),基尼系数越小,则不纯度越低,这和 C4.5 的信息增益比恰好相反。
其中,k 表示类别数, 表示第 k 个类别的概率。
上式中,
假设使用特征 A 将数据集 D 划分为两部分 D1 和 D2,此时按照特征 A 划分的数据集的基尼系数为:
说明:因为 CART 算法生成的树是二叉树,因此特征选择过程中特征的取值只有两种可能,这样就能确保当前节点仅有两个子节点。
使用基尼系数来代替信息熵存在一定的误差,从上图可以看出,基尼系数和熵之半的曲线非常接近,仅仅在 45 度角附近误差稍大。因此,基尼系数可以做为熵模型的一个近似替代。
【与信息增益的比较】:无论是信息增益还是信息增益比,其中都涉及大量的对数运算,计算开销自然要比普通的乘除操作要大。使用基尼系数可以减少计算量,起到简化模型的作用,并且也不会完全丢失熵模型的优点。
均方误差
CART 回归树的度量目标是,对于任意划分特征 A,对应的任意划分点 s,可切分成数据集 D1 和 D2,求出使 D1 和 D2 各自集合的均方差最小,同时 D1 和 D2 的均方差之和最小所对应的特征和特征值划分点。
其中,c1 为 D1 数据集的样本输出均值,c2 为 D2 数据集的样本输出均值。
需要注意的是,这里的均方误差和平时所见到的均方误差有所不同。一般来说,均方误差的计算形式是:
其中
这是因为在决策树模型还未建立前是无法求出具体的预测值 ,所以只好用每一个类别的均值作为这个类别的预测值。
构建分类树
CART 算法构建分类树的步骤与 C4.5 与 ID3 相似,不同点在于特征选择以及生成的树是二叉树。在此,主要谈谈特征选择如何进行。
【示例】:假设某个特征 A 被选取建立决策树节点,特征 A 有 A1,A2,A3 三种取值情况。
- 首先 CART 分类树会考虑把 A 分成 {A1} 和 {A2, A3},{A2} 和 {A1, A3},{A3} 和 {A1, A2} 三种情况。
- 然后,找到基尼系数最小的组合,假设该组合是 {A2} 和 {A1, A3}。
- 接着,建立二叉树节点,一个节点是 A2 对应的样本,另一个节点是 {A1, A3} 对应的节点。
需要注意的是,由于这次没有把特征 A 的取值完全分开,之后还有机会在子节点继续选择到特征 A 来划分 A1 和 A3。
构建回归树
在树的构建过程中需要解决多种类型数据的存储问题,这里通过字典(dict)来存储树的数据结构。该字典包含以下四个元素:
- 待切分的特征
- 待切分的特征值
- 右子树:当不再需要切分的时候,也可以是单个值
- 左子树:同右子树
观察存储树的字典可以发现,它只有两个子节点,而 ID3、C4.5 算法中的树一般都有两个或两个以上的子节点。
除了通过字典的方式来表示树结构外,我们还可以用面向对象编程模式中的类来建立树的数据结构。
class treeNode():
def __init__(self, feat, val, right, left):
feature_to_split_on = feat
value_of_split = val
right_branch = right
left_branch = left
【建议】:Python 具有足够的灵活性,可以直接使用字典来存储树结构而无须另外自定义一个类,从而有效地减少代码量。
有了树的数据结构之后,我们要考虑如何创建树?将创建树的函数命名为 create_tree(),下面给出该函数的伪代码:
找到最佳的待切分特征:
如果该节点不能再分,将该节点存为叶节点
执行二元切分
在右子树调用 create_tree() 方法
在左子树调用 create_tree() 方法
def bin_split_dataset(dataset, feature, value):
mat_0 = dataset[np.nonzero(dataset[:, feature] > value)[0], :]
mat_1 = dataset[np.nonzero(dataset[:, feature] <= value)[0], :]
return mat_0, mat_1
def create_tree(dataset, leaf_type=reg_leaf, err_type=reg_err, ops=(1, 4)):
feat, val = choose_best_split(dataset, leaf_type, err_type, ops)
if feat == None:
return val
ret_tree = {}
ret_tree['spInd'] = feat
ret_tree['spVal'] = val
lset, rset = bin_split_dataset(dataset, feat, val)
ret_tree['left'] = create_tree(lset, leaf_type, err_type, ops)
ret_tree['right'] = create_tree(rset, leaf_type, err_type, ops)
return ret_tree
bin_split_dataset()
bin_split_dataset() 函数用以在给定特征和特征值的情况下,对数据集进行切分,从而得到并返回两个子集。该函数接受三个参数,分别是数据集、待切分的特征和该特征的值。
mat_0 = dataset[np.nonzero(dataset[:, feature] > value)[0], :]
mat_1 = dataset[np.nonzero(dataset[:, feature] <= value)[0], :]
return mat_0, mat_1
【注意】:《机器学习实战》关于这部分的代码存在错误,我们需要将 mat_0 以及 mat_1 这两条语句最后的 [0] 给去除。因为我们需要的是两个集合,而不是具体的一条数据。
create_tree()
create_tree() 函数用以创建树,该函数接受四个参数,数据集和其他 3 个可选参数。这些可选参数决定了树的类型:
- leat_tree:给出建立叶节点的函数;
- err_type:代表误差计算函数;
- ops:包含构建树所需其他参数的元组。
- create_tree() 函数是一个递归函数,首先尝试将数据集分成两个部分,切分由函数 choose_best_split() (后续会介绍,顾名思义是获取最好的划分)函数完成,该函数返回特征以及特征值。
feat, val = choose_best_split(dataset, leaf_type, err_type, ops)
- 如果满足停止条件——无需继续划分,则返回特征值。
if feat == None:
return val
- 如果不满足停止条件(也就是说需要继续划分),则创建一个新的字典,并将数据集分成两份,在这两份子集上分别继续递归调用 create_tree() 函数。
ret_tree = {}
ret_tree['spInd'] = feat
ret_tree['spVal'] = val
lset, rset = bin_split_dataset(dataset, feat, val)
ret_tree['left'] = create_tree(lset, leaf_type, err_type, ops)
ret_tree['right'] = create_tree(rset, leaf_type, err_type, ops)
return ret_tree
reg_leaf()
reg_leaf() 函数负责生成叶节点。当 choose_best_split() 函数确定不再对数据进行切分时,将调用 reg_leaf() 函数来得到叶节点的模型。在回归树中,该模型其实就是目标变量的均值。
def reg_leaf(dataset):
return np.mean(dataset[:, -1])
reg_err()
reg_err() 用以计算给定数据上目标变量的平方误差。我们可以直接调用 numpy 的 var() 函数来计算数据集的均方差,具体使用请参考官方文档 var()。在计算完数据集的均方差之后,我们再用均方差乘以数据集中样本的个数来获得总方差。
def reg_err(dataset):
return np.var(dataset[:, -1]) * np.shape(dataset)[0]
choose_best_split()
choose_best_split() 函数是回归树构建的核心函数,目的是找到数据的最佳二元切分方式。该函数接受四个参数:
- dataset:数据集
- leaf_type:生成叶节点的函数的引用
- err_type:误差估计函数的引用
- ops:用户指定的参数,用于控制函数的停止时机。
- tol_s:容许的误差下降值;
- tol_n:切分的最小样本数。
def choose_best_split(dataset, leaf_type=reg_leaf, err_type=reg_err, ops=(1, 4)):
tol_s, tol_n = ops[0], ops[1]
# 如果所有标签的值都相等,则无须切分可直接退出
if dataset[:, -1].shape[0] == 1:
return None
m, n = np.shape(dataset)
s = err_type(dataset)
best_s, best_index, best_value = np.inf, 0, 0
for feat_index in range(n - 1):
for split_val in set(dataset[:, feat_index]):
mat_0, mat_1 = bin_split_dataset(dataset, feat_index, split_val)
if (np.shape[mat_0][0] < tol_n) or (np.shape(mat_1)[0] < tol_n):
continue
new_s = err_type(mat_0) + err_type(mat_1)
if new_s < best_s:
best_index = feat_index
best_value = split_val
best_s = new_s
# 如果误差减少不大则退出
if (s - best_s) < tol_s:
return None, leaf_type(dataset)
mat_0, mat_1 = bin_split_dataset(dataset, best_index, best_value)
# 如果切分出的数据集很小则退出
if (np.shape(mat_0)[0] < tol_n) or (np.shape(mat_1)[0] < tol_n):
return None, leaf_type(dataset)
return best_index, best_value
- 首先判断当前数据集的标签种类,若都相等,则没有必要继续划分。怎么实现呢?对数据集建立一个集合,然后统计标签的数目,若为 1,则不需要再切分,可以直接返回。
if dataset[:, -1].shape[0] == 1:
return None
- 然后计算当前数据集的样本个数以及特征数,并计算数据集的误差。该误差将用于与新切分的误差进行对比,来检查新切分能否降低误差。
m, n = np.shape(dataset)
s = err_type(dataset)
- 初始化所需的变量,最小的误差、最佳的切分值以及最佳切分值对应的下标。
best_s, best_index, best_value = np.inf, 0, 0
- 接着在所有可能的特征及其可能取值上进行遍历,找到最佳的切分方式。需要注意的是,在循环遍历的过程中,若当前切分使得切分后的子集(mat_0 或 mat_1)的样本个数小于用户设定的最少样本数,则取消当前切分。
# 所有可能的特征
for feat_index in range(n - 1):
# 所有可能的特征取值
for split_val in set(dataset[:, feat_index]):
mat_0, mat_1 = bin_split_dataset(dataset, feat_index, split_val)
# 若切分后左子树或右子树小于用户设定的最少样本数,则取消当前切分
if (np.shape[mat_0][0] < tol_n) or (np.shape(mat_1)[0] < tol_n):
continue
new_s = err_type(mat_0) + err_type(mat_1)
# 若当前切分后的误差减小,则更新最小误差、最佳切分值以及最佳切分值对应的下标
if new_s < best_s:
best_index = feat_index
best_value = split_val
best_s = new_s
- 在循环结束后,如果切分数据集后效果提升不够大,那么就不必进行切分操作而直接创建叶节点。
if (s - best_s) < tol_s:
return None, leaf_type(dataset)
- 另外,再检查两个切分后的子集的样本个数,若某个子集的样本个数小于用户定义的最少样本数,那么也不应切分。
mat_0, mat_1 = bin_split_dataset(dataset, best_index, best_value)
if (np.shape(mat_0)[0] < tol_n) or (np.shape(mat_1)[0] < tol_n):
return None, leaf_type(dataset)
- 如果这些提前终止条件都不满足,就返回切分特征和特征值。
return best_index, best_value
运行代码
下面在一些数据上查看代码的实际效果。
>>> my_dat = load_dataset('data/ex00.txt')
>>> my_dat = np.mat(my_dat)
>>> fig = plt.figure()
>>> ax = fig.add_subplot(111)
>>> ax.scatter(my_dat[:, 0].tolist(), my_dat[:, 1].tolist())
>>> ax.set_title('ex00.txt dataset')
>>> ax.set_xlabel('X')
>>> ax.set_ylabel('Y')
>>> plt.show()
接着创建回归树。
>>> create_tree(my_dat)
{'spInd': 0,
'spVal': 0.48813,
'left': 1.0180967672413792,
'right': -0.04465028571428572}
我们再看另外一个多次切分的例子。
>>> my_dat1 = load_dataset('data/ex0.txt')
>>> my_dat1 = np.mat(my_dat1)
>>> fig = plt.figure()
>>. ax = fig.add_subplot(111)
>>> ax.scatter(my_dat1[:, 1].tolist(), my_dat1[:, 2].tolist())
>>> ax.set_title('ex0.txt dataset')
>>> ax.set_xlabel('X')
>>> ax.set_ylabel('Y')
>>> plt.show()
为数据集 my_dat2 创建回归树。
>>> create_tree(my_dat1)
{'spInd': 1,
'spVal': 0.39435,
'left': {'spInd': 1,
'spVal': 0.582002,
'left': {'spInd': 1,
'spVal': 0.797583,
'left': 3.9871632,
'right': 2.9836209534883724},
'right': 1.980035071428571},
'right': {'spInd': 1,
'spVal': 0.197834,
'left': 1.0289583666666666,
'right': -0.023838155555555553}}
可以看到该树包含 5 个叶节点和数据集的情况相符合。到现在为止,已经完成回归树的构建,但是需要某种措施来检查构建过程是否得当。下面将介绍树剪枝(tree pruning)技术,它通过对决策树剪枝来达到更好的预测效果。
树剪枝
一棵树如果节点过多,则表明该模型可能对数据进行了“过拟合”。我们可通过降低决策树的复杂度来避免过拟合,最有效的手段是进行剪枝处理(pruning)。
先前在函数 choose_best_split() 中的提前终止条件,实际上在进行一种所谓的预剪枝(prepruning)操作。另一种形式的剪枝需要使用测试集和训练集,称作后剪枝(postpruning)。接下来,我们将先讨论预剪枝存在的不足之处,然后再讨论后剪枝的处理方式。